diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e15c70..728baf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to this project will be documented in this file. +## [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. +- Fix issue with error on dev console on report page due to resources duplication +- Fix issue with Custom querysets not being correctly connected in the view +- Fix issue with time series custom dates +- Fix issue with Crosstab on traversing fields + ## [1.0.1] - 2023-07-03 @@ -11,7 +19,7 @@ All notable changes to this project will be documented in this file. ## [1.0.0] - 2023-07-03 - Added crosstab_ids_custom_filters to allow custom filters on crosstab ids -- Added ``group_by_querysets`` to allow custom querysets as group +- Added ``group_by_custom_querysets`` to allow custom querysets as group - Added ability to have crosstab report in a time series report - Enhanced Docs content and structure. diff --git a/README.rst b/README.rst index ddc0574..f89f057 100644 --- a/README.rst +++ b/README.rst @@ -61,26 +61,30 @@ You can simply use a code like this class TotalProductSales(ReportView): - - report_model = MySalesItems - date_field = "date_placed" + report_model = SalesTransaction + date_field = "date" group_by = "product" columns = [ - "title", - SlickReportField.create(Sum, "quantity"), - SlickReportField.create(Sum, "value", name="sum__value"), + "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 $"), ] chart_settings = [ Chart( "Total sold $", Chart.BAR, - data_source="value__sum", - title_source="title", + data_source=["sum__value"], + title_source=["name"], + ), + Chart( + "Total sold $ [PIE]", + Chart.PIE, + data_source=["sum__value"], + title_source=["name"], ), ] - To get something this .. image:: https://i.ibb.co/SvxTM23/Selection-294.png @@ -190,16 +194,38 @@ You can interact with the `ReportGenerator` using same syntax as used with the ` my_report.get_report_data() # -> [{'title':'Product 1', '__total__: 56}, {'title':'Product 2', '__total__: 43}, ] -This is just a scratch, for more please visit the documentation +This is just a scratch of what you can do and customize. + +Demo site +--------- + +Available on `Django Slick Reporting `_ + + +You can also use locally + +.. code-block:: console + + # clone the repo + # create a virtual environment, activate it, then + cd django-slick-reporting/demo_proj + pip install requirements.txt + python manage.py migrate + python manage.py create_entries + python manage.py runserver + +the ``create_entries`` command will generate data for the demo app + Batteries Included ------------------ Slick Reporting comes with -* A Bootstrap Filter Form -* Charting support `Chart.js `_ -* Powerful tables `datatables.net `_ +* An auto-generated, bootstrap-ready Filter Form +* Carts.js Charting support `Chart.js `_ +* Highcharts.js Charting support `Highcharts.js `_ +* Datatables `datatables.net `_ A Preview: @@ -208,11 +234,6 @@ A Preview: :alt: Shipped in View Page -Demo site ---------- - -Available on `Django Slick Reporting `_ - Documentation ------------- @@ -221,11 +242,8 @@ Available on `Read The Docs `_ Display Django permissions in a HTML table that is translatable and easy customized. -`Django Ra ERP Framework `_ A framework to build business solutions with ease. +`Django ERP Framework `_ A framework to build business solutions with ease. If you find this project useful or promising , You can support us by a github ⭐ diff --git a/demo_proj/demo_app/__init__.py b/demo_proj/demo_app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/demo_proj/demo_app/admin.py b/demo_proj/demo_app/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/demo_proj/demo_app/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/demo_proj/demo_app/apps.py b/demo_proj/demo_app/apps.py new file mode 100644 index 0000000..dba8cee --- /dev/null +++ b/demo_proj/demo_app/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class DemoAppConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "demo_app" diff --git a/demo_proj/demo_app/forms.py b/demo_proj/demo_app/forms.py new file mode 100644 index 0000000..65c56d9 --- /dev/null +++ b/demo_proj/demo_app/forms.py @@ -0,0 +1,45 @@ +from django import forms +from django.db.models import Q +from slick_reporting.forms import BaseReportForm + + +class TotalSalesFilterForm(BaseReportForm, forms.Form): + PRODUCT_SIZE_CHOICES = ( + ("all", "All"), + ("big-only", "Big Only"), + ("small-only", "Small Only"), + ("medium-only", "Medium Only"), + ("all-except-extra-big", "All except extra Big"), + ) + start_date = forms.DateField( + required=False, + label="Start Date", + widget=forms.DateInput(attrs={"type": "date"}), + ) + end_date = forms.DateField( + required=False, label="End Date", widget=forms.DateInput(attrs={"type": "date"}) + ) + product_size = forms.ChoiceField( + choices=PRODUCT_SIZE_CHOICES, required=False, label="Product Size", initial="all" + ) + + def get_filters(self): + # return the filters to be used in the report + # Note: the use of Q filters and kwargs filters + kw_filters = {} + q_filters = [] + if self.cleaned_data["product_size"] == "big-only": + kw_filters["product__size__in"] = ["extra_big", "big"] + elif self.cleaned_data["product_size"] == "small-only": + kw_filters["product__size__in"] = ["extra_small", "small"] + elif self.cleaned_data["product_size"] == "medium-only": + kw_filters["product__size__in"] = ["medium"] + elif self.cleaned_data["product_size"] == "all-except-extra-big": + q_filters.append(~Q(product__size__in=["extra_big", "big"])) + return q_filters, kw_filters + + def get_start_date(self): + return self.cleaned_data["start_date"] + + def get_end_date(self): + return self.cleaned_data["end_date"] diff --git a/demo_proj/demo_app/management/commands/create_entries.py b/demo_proj/demo_app/management/commands/create_entries.py new file mode 100644 index 0000000..f9f3589 --- /dev/null +++ b/demo_proj/demo_app/management/commands/create_entries.py @@ -0,0 +1,90 @@ +import datetime +import random +from datetime import timedelta + +from django.contrib.auth import get_user_model +from django.core.management.base import BaseCommand + +# from expense.models import Expense, ExpenseTransaction +from ...models import Client, Product, SalesTransaction, ProductCategory + +User = get_user_model() + + +def date_range(start_date, end_date): + for i in range((end_date - start_date).days + 1): + yield start_date + timedelta(i) + + +class Command(BaseCommand): + help = "Create Sample entries for the demo app" + + def handle(self, *args, **options): + # create clients + models_list = [ + Client, + Product, + ] + client_countries = [ + "US", + "DE", + "EG", + "IN", + "KW", + "RA" + ] + product_category = [ + "extra_big", + "big", + "medium", + "small", + "extra-small" + ] + SalesTransaction.objects.all().delete() + Client.objects.all().delete() + Product.objects.all().delete() + ProductCategory.objects.all().delete() + User.objects.filter(is_superuser=False).delete() + 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)) + for i in range(1, 4): + ProductCategory.objects.create(name=f"Product Category {i}") + + product_category_ids = list(ProductCategory.objects.values_list("id", flat=True)) + for i in range(1, 10): + Client.objects.create(name=f"Client {i}", + country=random.choice(client_countries), + # owner_id=random.choice(users_id) + ) + clients_ids = list(Client.objects.values_list("pk", flat=True)) + # create products + for i in range(1, 10): + Product.objects.create(name=f"Product {i}", + product_category_id=random.choice(product_category_ids), + size=random.choice(product_category)) + products_ids = list(Product.objects.values_list("pk", flat=True)) + + current_year = datetime.datetime.today().year + start_date = datetime.datetime(current_year, 1, 1) + end_date = datetime.datetime(current_year + 1, 1, 1) + + for date in date_range(start_date, end_date): + for i in range(1, 10): + SalesTransaction.objects.create( + client_id=random.choice(clients_ids), + product_id=random.choice(products_ids), + quantity=random.randint(1, 10), + price=random.randint(1, 100), + date=date, + number=f"Sale {date.strftime('%Y-%m-%d')} #{i}", + ) + # ExpenseTransaction.objects.create( + # expense_id=random.choice(expense_ids), + # value=random.randint(1, 100), + # date=date, + # number=f"Expense {date.strftime('%Y-%m-%d')} #{i}", + # ) + + self.stdout.write(self.style.SUCCESS("Entries Created Successfully")) diff --git a/demo_proj/demo_app/migrations/0001_initial.py b/demo_proj/demo_app/migrations/0001_initial.py new file mode 100644 index 0000000..b0ef288 --- /dev/null +++ b/demo_proj/demo_app/migrations/0001_initial.py @@ -0,0 +1,94 @@ +# Generated by Django 4.2 on 2023-08-02 09:14 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="Client", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=100, verbose_name="Client Name")), + ], + options={ + "verbose_name": "Client", + "verbose_name_plural": "Clients", + }, + ), + migrations.CreateModel( + name="Product", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=100, verbose_name="Product Name")), + ], + options={ + "verbose_name": "Product", + "verbose_name_plural": "Products", + }, + ), + migrations.CreateModel( + name="SalesTransaction", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "number", + models.CharField( + max_length=100, verbose_name="Sales Transaction #" + ), + ), + ("date", models.DateTimeField()), + ("notes", models.TextField(blank=True, null=True)), + ("value", models.DecimalField(decimal_places=2, max_digits=9)), + ( + "client", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + to="demo_app.client", + verbose_name="Client", + ), + ), + ( + "product", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + to="demo_app.product", + verbose_name="Product", + ), + ), + ], + options={ + "verbose_name": "Sales Transaction", + "verbose_name_plural": "Sales Transactions", + }, + ), + ] diff --git a/demo_proj/demo_app/migrations/0002_salestransaction_price_salestransaction_quantity.py b/demo_proj/demo_app/migrations/0002_salestransaction_price_salestransaction_quantity.py new file mode 100644 index 0000000..5870520 --- /dev/null +++ b/demo_proj/demo_app/migrations/0002_salestransaction_price_salestransaction_quantity.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2 on 2023-08-02 09:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("demo_app", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="salestransaction", + name="price", + field=models.DecimalField(decimal_places=2, default=0, max_digits=9), + preserve_default=False, + ), + migrations.AddField( + model_name="salestransaction", + name="quantity", + field=models.DecimalField(decimal_places=2, default=0, max_digits=9), + preserve_default=False, + ), + ] diff --git a/demo_proj/demo_app/migrations/0003_product_category.py b/demo_proj/demo_app/migrations/0003_product_category.py new file mode 100644 index 0000000..78eb509 --- /dev/null +++ b/demo_proj/demo_app/migrations/0003_product_category.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2 on 2023-08-30 08:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("demo_app", "0002_salestransaction_price_salestransaction_quantity"), + ] + + operations = [ + migrations.AddField( + model_name="product", + name="category", + field=models.CharField( + default="Medium", max_length=100, verbose_name="Product Category" + ), + ), + ] diff --git a/demo_proj/demo_app/migrations/__init__.py b/demo_proj/demo_app/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/demo_proj/demo_app/models.py b/demo_proj/demo_app/models.py new file mode 100644 index 0000000..e9e10ca --- /dev/null +++ b/demo_proj/demo_app/models.py @@ -0,0 +1,68 @@ +import uuid + +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +# Create your models here. +class Client(models.Model): + name = models.CharField(max_length=100, verbose_name="Client Name") + country = models.CharField(_("Country"), max_length=255, default="US") + + class Meta: + verbose_name = _("Client") + verbose_name_plural = _("Clients") + + def __str__(self): + return self.name + + +class ProductCategory(models.Model): + name = models.CharField(max_length=100, verbose_name="Product Category Name") + + def __str__(self): + return self.name + + +class Product(models.Model): + name = models.CharField(max_length=100, verbose_name="Product Name") + # category = models.CharField(max_length=100, verbose_name="Product Category", default="Medium") + product_category = models.ForeignKey(ProductCategory, on_delete=models.CASCADE, null=True) + + sku = models.CharField(_("SKU"), max_length=255, default=uuid.uuid4) + size = models.CharField(max_length=100, verbose_name="Size", default="Medium") + + class Meta: + verbose_name = _("Product") + verbose_name_plural = _("Products") + + def __str__(self): + return self.name + + +class SalesTransaction(models.Model): + number = models.CharField(max_length=100, verbose_name="Sales Transaction #") + date = models.DateTimeField() + notes = models.TextField(blank=True, null=True) + client = models.ForeignKey( + Client, on_delete=models.PROTECT, verbose_name=_("Client") + ) + product = models.ForeignKey( + Product, on_delete=models.PROTECT, verbose_name=_("Product") + ) + value = models.DecimalField(max_digits=9, decimal_places=2) + quantity = models.DecimalField(max_digits=9, decimal_places=2) + price = models.DecimalField(max_digits=9, decimal_places=2) + + class Meta: + verbose_name = _("Sales Transaction") + verbose_name_plural = _("Sales Transactions") + + def __str__(self): + return f"{self.number} - {self.date}" + + def save( + self, force_insert=False, force_update=False, using=None, update_fields=None + ): + self.value = self.price * self.quantity + super().save(force_insert, force_update, using, update_fields) diff --git a/demo_proj/demo_app/reports.py b/demo_proj/demo_app/reports.py new file mode 100644 index 0000000..a6d9233 --- /dev/null +++ b/demo_proj/demo_app/reports.py @@ -0,0 +1,370 @@ +import datetime + +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 + + +class ProductSales(ReportView): + report_model = SalesTransaction + date_field = "date" + group_by = "product" + + columns = [ + "name", + SlickReportField.create( + method=Sum, field="value", name="value__sum", verbose_name="Total sold $", is_summable=True, + ), + ] + + # Charts + chart_settings = [ + Chart( + "Total sold $", + Chart.BAR, + data_source=["value__sum"], + title_source=["name"], + ), + ] + + +class TotalProductSales(ReportView): + 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 $"), + ] + + chart_settings = [ + Chart( + "Total sold $", + Chart.BAR, + data_source=["sum__value"], + title_source=["name"], + ), + Chart( + "Total sold $ [PIE]", + Chart.PIE, + data_source=["sum__value"], + title_source=["name"], + ), + ] + + +class TotalProductSalesByCountry(ReportView): + 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 $"), + ] + + chart_settings = [ + Chart( + "Total sold by country $", + Chart.PIE, # A Pie Chart + data_source=["sum__value"], + title_source=["client__country"], + ), + ] + + +from django.utils.translation import gettext_lazy as _ + + +class SumValueComputationField(SlickReportField): + computation_method = Sum + computation_field = "value" + verbose_name = _("Sales Value") + name = "my_value_sum" + + +class MonthlyProductSales(ReportView): + report_model = SalesTransaction + date_field = "date" + group_by = "product" + columns = ["name", "sku"] + + time_series_pattern = "monthly" + time_series_columns = [ + SumValueComputationField, + ] + + chart_settings = [ + Chart( + _("Total Sales Monthly"), + Chart.PIE, + data_source=["my_value_sum"], + title_source=["name"], + plot_total=True, + ), + Chart( + _("Sales Monthly [Bar]"), + Chart.COLUMN, + data_source=["my_value_sum"], + title_source=["name"], + ), + ] + + +class ProductSalesPerClientCrosstab(ReportView): + report_model = SalesTransaction + date_field = "date" + group_by = "product" + crosstab_field = "client" + + crosstab_columns = [ + SumValueComputationField, + ] + + # crosstab_ids = ["US", "KW", "EG", "DE"] + crosstab_compute_remainder = True + + columns = [ + "name", + "sku", + "__crosstab__", + SumValueComputationField, + ] + + +class ProductSalesPerCountryCrosstab(ReportView): + report_model = SalesTransaction + date_field = "date" + group_by = "product" + crosstab_field = "client__country" + crosstab_columns = [ + SumValueComputationField, + ] + + crosstab_ids = ["US", "KW", "EG", "DE"] + crosstab_compute_remainder = True + + columns = [ + "name", + "sku", + "__crosstab__", + SumValueComputationField, + ] + + +from slick_reporting.views import ListReportView + + +class LastTenSales(ListReportView): + report_model = SalesTransaction + report_title = "Last 10 sales" + date_field = "date" + filters = ["client"] + columns = [ + "product", + "date", + "quantity", + "price", + "value", + ] + default_order_by = "-date" + limit_records = 10 + + +class TotalProductSalesWithCustomForm(TotalProductSales): + report_title = _("Total Product Sales with Custom Form") + form_class = TotalSalesFilterForm + 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 $"), + ] + + +class GroupByReport(ReportView): + report_model = SalesTransaction + report_title = _("Group By Report") + date_field = "date" + group_by = "product" + + columns = [ + "name", + SlickReportField.create( + method=Sum, field="value", name="value__sum", verbose_name="Total sold $", is_summable=True, + ), + ] + + # Charts + chart_settings = [ + Chart( + "Total sold $", + Chart.BAR, + data_source=["value__sum"], + title_source=["name"], + ), + ] + + +class GroupByTraversingFieldReport(GroupByReport): + report_title = _("Group By Traversing Field") + group_by = "product__product_category" + + +class GroupByCustomQueryset(ReportView): + report_model = SalesTransaction + report_title = _("Group By Custom Queryset") + date_field = "date" + + group_by_custom_querysets = [ + SalesTransaction.objects.filter(product__size__in=["big", "extra_big"]), + SalesTransaction.objects.filter(product__size__in=["small", "extra_small"]), + SalesTransaction.objects.filter(product__size="medium"), + ] + group_by_custom_querysets_column_verbose_name = _("Product Size") + + columns = [ + "__index__", + SlickReportField.create(Sum, "value", verbose_name=_("Total Sold $"), name="value"), + ] + + chart_settings = [ + Chart( + title="Total sold By Size $", + type=Chart.PIE, + data_source=["value"], + title_source=["__index__"], + ), + Chart( + title="Total sold By Size $", + type=Chart.BAR, + data_source=["value"], + title_source=["__index__"], + ), + ] + + def format_row(self, row_obj): + # Put the verbose names we need instead of the integer index + index = row_obj['__index__'] + if index == 0: + row_obj["__index__"] = "Big" + elif index == 1: + row_obj['__index__'] = "Small" + elif index == 2: + row_obj['__index__'] = "Medium" + return row_obj + + +class TimeSeriesReport(ReportView): + report_model = SalesTransaction + group_by = "client" + time_series_pattern = "monthly" + # options are : "daily", "weekly", "bi-weekly", "monthly", "quarterly", "semiannually", "annually" and "custom" + + date_field = "date" + time_series_columns = [ + SlickReportField.create(Sum, "value", verbose_name=_("Sales For ")), + ] + # These columns will be calculated for each period in the time series. + + columns = [ + "name", + "__time_series__", + # placeholder for the generated time series columns + + SlickReportField.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=["name"], + ), + Chart("Total Sales [Pie]", + Chart.PIE, + data_source=["sum__value"], + title_source=["name"], + plot_total=True, + ), + Chart("Total Sales [Area chart]", + Chart.AREA, + data_source=["sum__value"], + title_source=["name"], + ) + ] + + +class TimeSeriesReportWithSelector(TimeSeriesReport): + report_title = _("Time Series Report With Pattern Selector") + time_series_selector = True + time_series_selector_choices = ( + ("daily", _("Daily")), + ("weekly", _("Weekly")), + ("bi-weekly", _("Bi-Weekly")), + ("monthly", _("Monthly")), + ) + time_series_selector_default = "bi-weekly" + + time_series_selector_label = _("Period Pattern") + # The label for the time series selector + + time_series_selector_allow_empty = True + # Allow the user to select an empty time series, in which case no time series will be applied to the report. + + +def get_current_year(): + return datetime.datetime.now().year + + +class TimeSeriesReportWithCustomDates(TimeSeriesReport): + report_title = _("Time Series Report With Custom Dates") + time_series_pattern = "custom" + time_series_custom_dates = ( + (datetime.datetime(get_current_year(), 1, 1), datetime.datetime(get_current_year(), 1, 10)), + (datetime.datetime(get_current_year(), 2, 1), datetime.datetime(get_current_year(), 2, 10)), + (datetime.datetime(get_current_year(), 3, 1), datetime.datetime(get_current_year(), 3, 10)), + ) + + +class SumOfFieldValue(SlickReportField): + # A custom computation Field identical to the one created like this + # Similar to `SlickReportField.create(Sum, "value", verbose_name=_("Total Sales"))` + + calculation_method = Sum + calculation_field = "value" + name = "sum_of_value" + + @classmethod + def get_time_series_field_verbose_name(cls, date_period, index, dates, pattern): + # date_period: is a tuple (start_date, end_date) + # index is the index of the current pattern in the patterns on the report + # dates: the whole dates we have on the reports + # pattern it's the pattern name, ex: monthly, daily, custom + return f"First 10 days sales {date_period[0].month}-{date_period[0].year}" + + +class TimeSeriesReportWithCustomDatesAndCustomTitle(TimeSeriesReportWithCustomDates): + 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 + ] + + chart_settings = [ + Chart("Client Sales", + Chart.BAR, + data_source=["sum_of_value"], # Note: This is the name of our `TotalSalesField` `field + title_source=["name"], + ), + Chart("Total Sales [Pie]", + Chart.PIE, + data_source=["sum_of_value"], + title_source=["name"], + plot_total=True, + ), + ] diff --git a/demo_proj/demo_app/tests.py b/demo_proj/demo_app/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/demo_proj/demo_app/tests.py @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/demo_proj/demo_app/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/demo_proj/demo_proj/__init__.py b/demo_proj/demo_proj/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/demo_proj/demo_proj/asgi.py b/demo_proj/demo_proj/asgi.py new file mode 100644 index 0000000..34375d8 --- /dev/null +++ b/demo_proj/demo_proj/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for demo_proj project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "demo_proj.settings") + +application = get_asgi_application() diff --git a/demo_proj/demo_proj/settings.py b/demo_proj/demo_proj/settings.py new file mode 100644 index 0000000..d3b6f9b --- /dev/null +++ b/demo_proj/demo_proj/settings.py @@ -0,0 +1,131 @@ +""" +Django settings for demo_proj project. + +Generated by 'django-admin startproject' using Django 4.2. + +For more information on this file, see +https://docs.djangoproject.com/en/4.2/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/4.2/ref/settings/ +""" + +from pathlib import Path + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = "django-insecure-kb+5wbkzz-dxvmzs%49y07g7zkk9@30w%+u@2@d5x!)daivk&7" + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + + "demo_app", + "crispy_forms", + "crispy_bootstrap4", + "slick_reporting", + # "slick_reporting.dashboards", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "demo_proj.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "demo_proj.wsgi.application" + + +# Database +# https://docs.djangoproject.com/en/4.2/ref/settings/#databases + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", + } +} + + +# Password validation +# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/4.2/topics/i18n/ + +LANGUAGE_CODE = "en-us" + +TIME_ZONE = "UTC" + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/4.2/howto/static-files/ + +STATIC_URL = "static/" + +# Default primary key field type +# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +CRISPY_TEMPLATE_PACK = "bootstrap4" \ No newline at end of file diff --git a/demo_proj/demo_proj/urls.py b/demo_proj/demo_proj/urls.py new file mode 100644 index 0000000..d1026a1 --- /dev/null +++ b/demo_proj/demo_proj/urls.py @@ -0,0 +1,53 @@ +""" +URL configuration for demo_proj project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/4.2/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import path + +from demo_app.reports import ProductSales, TotalProductSales, TotalProductSalesByCountry, MonthlyProductSales, \ + ProductSalesPerCountryCrosstab, ProductSalesPerClientCrosstab, LastTenSales, TotalProductSalesWithCustomForm, \ + GroupByReport, GroupByTraversingFieldReport, GroupByCustomQueryset, TimeSeriesReport + +from demo_app import reports + +urlpatterns = [ + 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(), + name="total-product-sales-by-country"), + path("monthly-product-sales/", MonthlyProductSales.as_view(), name="monthly-product-sales"), + path("product-sales-per-client-crosstab/", ProductSalesPerClientCrosstab.as_view(), + name="product-sales-per-client-crosstab"), + 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"), + + 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("time-series-report/", TimeSeriesReport.as_view(), name="time-series-report"), + path("time-series-with-selector/", reports.TimeSeriesReportWithSelector.as_view(), + name="time-series-with-selector"), + path("time-series-with-custom-dates/", reports.TimeSeriesReportWithCustomDates.as_view(), + 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("admin/", admin.site.urls), +] diff --git a/demo_proj/demo_proj/wsgi.py b/demo_proj/demo_proj/wsgi.py new file mode 100644 index 0000000..4156325 --- /dev/null +++ b/demo_proj/demo_proj/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for demo_proj project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "demo_proj.settings") + +application = get_wsgi_application() diff --git a/demo_proj/manage.py b/demo_proj/manage.py new file mode 100755 index 0000000..d764cb7 --- /dev/null +++ b/demo_proj/manage.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "demo_proj.settings") + # add slick reporting to path so that it can be imported + BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + sys.path.append(os.path.abspath(BASE_DIR)) + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/demo_proj/requirements.txt b/demo_proj/requirements.txt new file mode 100644 index 0000000..5da9de1 --- /dev/null +++ b/demo_proj/requirements.txt @@ -0,0 +1,5 @@ +django>=4.2 +python-dateutil>=2.8.1 +simplejson +django-crispy-forms +crispy-bootstrap4 \ No newline at end of file diff --git a/docs/source/index.rst b/docs/source/index.rst index 4269703..816af2a 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -23,7 +23,7 @@ Usage ----- #. Add ``"slick_reporting", "crispy_forms", "crispy_bootstrap4",`` to ``INSTALLED_APPS``. -#. Add ``CRISPY_TEMPLATE_PACK = 'bootstrap4'`` to your ``settings.py`` +#. Add ``CRISPY_TEMPLATE_PACK = "bootstrap4"`` to your ``settings.py`` #. Execute `python manage.py collectstatic` so the JS helpers are collected and served. @@ -36,9 +36,10 @@ You can start by using ``ReportView`` which is a subclass of ``django.views.gene .. code-block:: python # in views.py - from slick_reporting.views import ReportView - from slick_reporting.fields import SlickReportField, Chart + from slick_reporting.views import ReportView, Chart + from slick_reporting.fields import SlickReportField from .models import MySalesItems + from django.db.models import Sum class ProductSales(ReportView): @@ -50,17 +51,17 @@ You can start by using ``ReportView`` which is a subclass of ``django.views.gene columns = [ "title", SlickReportField.create( - method=Sum, field="value", name="value__sum", verbose_name=_("Total sold $") + method=Sum, field="value", name="value__sum", verbose_name="Total sold $" ), ] # Charts - charts_settings = [ + chart_settings = [ Chart( "Total sold $", Chart.BAR, - data_source="value__sum", - title_source="title", + data_source=["value__sum"], + title_source=["title"], ), ] diff --git a/docs/source/topics/group_by_report.rst b/docs/source/topics/group_by_report.rst index 88bdc45..8fa53e1 100644 --- a/docs/source/topics/group_by_report.rst +++ b/docs/source/topics/group_by_report.rst @@ -14,17 +14,29 @@ Example: .. code-block:: python - class ExpenseTotal(ReportView): - report_model = ExpenseTransaction - report_title = _("Expenses Daily") - group_by = "expense" - - columns = [ - "name", # name field on the expense model - SlickReportField.create( - Sum, "value", verbose_name=_("Total Expenditure"), name="value" - ), - ] + class GroupByReport(ReportView): + report_model = SalesTransaction + report_title = _("Group By Report") + date_field = "date" + group_by = "product" + + columns = [ + "name", + SlickReportField.create( + method=Sum, field="value", name="value__sum", verbose_name="Total sold $", is_summable=True, + ), + ] + + # Charts + chart_settings = [ + Chart( + "Total sold $", + Chart.BAR, + data_source=["value__sum"], + title_source=["name"], + ), + ] + A Sample group by report would look like this: @@ -46,15 +58,11 @@ Example: .. code-block:: python - class ExpenseTotal(ReportView): - report_model = ExpenseTransaction - report_title = _("Expenses Daily") - group_by = "expense__expensecategory" # Note the traversing + # Inherit from previous report and make another version, keeping the columns and charts + class GroupByTraversingFieldReport(GroupByReport): - columns = [ - "name", # name field on the ExpenseCategory model - SlickReportField.create(Sum, "value", verbose_name=_("Value"), name="value"), - ] + report_title = _("Group By Traversing Field") + group_by = "product__product_category" # Note the traversing @@ -67,24 +75,54 @@ Example: .. code-block:: python - class MyReport(ReportView): - report_model = MySales + class GroupByCustomQueryset(ReportView): + report_model = SalesTransaction + report_title = _("Group By Custom Queryset") + date_field = "date" - group_by_querysets = [ - MySales.objects.filter(status="pending"), - MySales.objects.filter(status__in=["paid", "overdue"]), + group_by_custom_querysets = [ + SalesTransaction.objects.filter(product__size__in=["big", "extra_big"]), + SalesTransaction.objects.filter(product__size__in=["small", "extra_small"]), + SalesTransaction.objects.filter(product__size="medium"), ] - group_by_custom_querysets_column_verbose_name = _("Status") + group_by_custom_querysets_column_verbose_name = _("Product Size") columns = [ "__index__", - SlickReportField.create(Sum, "value", verbose_name=_("Value"), name="value"), + SlickReportField.create(Sum, "value", verbose_name=_("Total Sold $"), name="value"), + ] + + chart_settings = [ + Chart( + title="Total sold By Size $", + type=Chart.PIE, + data_source=["value"], + title_source=["__index__"], + ), + Chart( + title="Total sold By Size $", + type=Chart.BAR, + data_source=["value"], + title_source=["__index__"], + ), ] + def format_row(self, row_obj): + # Put the verbose names we need instead of the integer index + index = row_obj['__index__'] + if index == 0: + row_obj["__index__"] = "Big" + elif index == 1: + row_obj['__index__'] = "Small" + elif index == 2: + row_obj['__index__'] = "Medium" + return row_obj + + This report will create two groups, one for pending sales and another for paid and overdue together. The ``__index__`` column is a "magic" column, it will added automatically to the report if it's not added. It just hold the index of the row in the group. its verbose name (ie the one on the table header) can be customized via ``group_by_custom_querysets_column_verbose_name`` -You can then customize the *value* of the __index__ column via ``filter_results`` hook +You can then customize the *value* of the __index__ column via ``format_row`` hook diff --git a/docs/source/topics/time_series_options.rst b/docs/source/topics/time_series_options.rst index e54c6fb..5adf363 100644 --- a/docs/source/topics/time_series_options.rst +++ b/docs/source/topics/time_series_options.rst @@ -6,7 +6,8 @@ Time Series Reports A Time series report is a report that is generated for a periods of time. The period can be daily, weekly, monthly, yearly or custom, calculations will be performed for each period in the time series. -Here is a quick recipe to what you want to do +Here is a quick look at the Typical use case + .. code-block:: python @@ -14,50 +15,150 @@ Here is a quick recipe to what you want to do from django.db.models import Sum from slick_reporting.views import ReportView + class TimeSeriesReport(ReportView): + report_model = SalesTransaction + group_by = "client" - class MyReport(ReportView): - - # options are : "daily", "weekly", "monthly", "yearly", "custom" time_series_pattern = "monthly" + # options are : "daily", "weekly", "bi-weekly", "monthly", "quarterly", "semiannually", "annually" and "custom" - - # if time_series_pattern is "custom", then you can specify the dates like so - time_series_custom_dates = [ - (datetime.date(2020, 1, 1), datetime.date(2020, 1, 14)), - (datetime.date(2020, 2, 1), datetime.date(2020, 2, 14)), - (datetime.date(2020, 3, 1), datetime.date(2020, 3,14)), - ] + date_field = "date" # These columns will be calculated for each period in the time series. time_series_columns = [ - SlickReportField.create(Sum, "value", verbose_name=_("Value")), + SlickReportField.create(Sum, "value", verbose_name=_("Sales For Month")), ] - columns = [ - "product_sku", - - # You can customize where the time series columns are displayed in relation to the other columns + "name", "__time_series__", # This is the same as the time_series_columns, but this one will be on the whole set - SlickReportField.create(Sum, "value", verbose_name=_("Value")), + SlickReportField.create(Sum, "value", verbose_name=_("Total Sales")), ] - # This will display a selector to change the time series pattern - time_series_selector = True + chart_settings = [ + Chart("Client Sales", + Chart.BAR, + data_source=["sum__value"], + title_source=["name"], + ), + Chart("Total Sales Monthly", + Chart.PIE, + data_source=["sum__value"], + title_source=["name"], + plot_total=True, + ), + Chart("Total Sales [Area chart]", + Chart.AREA, + data_source=["sum__value"], + title_source=["name"], + ) + ] + + +Allowing the User to Choose the time series pattern +--------------------------------------------------- - # settings for the time series selector - # ---------------------------------- - time_series_selector_choices = None # A list Choice tuple [(value, label), ...] - time_series_selector_default = ( - "monthly" # The initial value for the time series selector +You can allow the User to Set the Pattern for the report , Let's create another version of the above report +where the user can choose the pattern + +.. code-block:: python + + class TimeSeriesReportWithSelector(TimeSeriesReport): + report_title = _("Time Series Report With Pattern Selector") + time_series_selector = True + time_series_selector_choices = ( + ("daily", _("Daily")), + ("weekly", _("Weekly")), + ("bi-weekly", _("Bi-Weekly")), + ("monthly", _("Monthly")), ) - # The label for the time series selector + time_series_selector_default = "bi-weekly" + time_series_selector_label = _("Period Pattern") - # Allow the user to select an empty time series - time_series_selector_allow_empty = False + # The label for the time series selector + + time_series_selector_allow_empty = True + # Allow the user to select an empty time series, in which case no time series will be applied to the report. + + +Set Custom Dates for the Time Series +------------------------------------ + +You might want to set irregular pattern for the time series, +Like first 10 days of each month , or the 3 summer month of every year. + +Let's see how you can do that, inheriting from teh same Time series we did first. + +.. code-block:: python + + + def get_current_year(): + return datetime.datetime.now().year + + + class TimeSeriesReportWithCustomDates(TimeSeriesReport): + report_title = _("Time Series Report With Custom Dates") + time_series_pattern = "custom" + time_series_custom_dates = ( + (datetime.datetime(get_current_year(), 1, 1), datetime.datetime(get_current_year(), 1, 10)), + (datetime.datetime(get_current_year(), 2, 1), datetime.datetime(get_current_year(), 2, 10)), + (datetime.datetime(get_current_year(), 3, 1), datetime.datetime(get_current_year(), 3, 10)), + ) + + + +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 +how the title is created and use it in the time_series_column instead of the one created on the fly. + +Example: + +.. code-block:: python + + + class SumOfFieldValue(SlickReportField): + # A custom computation Field identical to the one created like this + # Similar to `SlickReportField.create(Sum, "value", verbose_name=_("Total Sales"))` + + calculation_method = Sum + calculation_field = "value" + name = "sum_of_value" + + @classmethod + def get_time_series_field_verbose_name(cls, date_period, index, dates, pattern): + # date_period: is a tuple (start_date, end_date) + # index is the index of the current pattern in the patterns on the report + # dates: the whole dates we have on the reports + # pattern it's the pattern name, ex: monthly, daily, custom + return f"First 10 days sales {date_period[0].month}-{date_period[0].year}" + + + class TimeSeriesReportWithCustomDatesAndCustomTitle(TimeSeriesReportWithCustomDates): + 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 + ] + + chart_settings = [ + Chart("Client Sales", + Chart.BAR, + data_source=["sum_of_value"], # Note: This is the name of our `TotalSalesField` `field + title_source=["name"], + ), + Chart("Total Sales [Pie]", + Chart.PIE, + data_source=["sum_of_value"], + title_source=["name"], + plot_total=True, + ), + ] + .. _time_series_options: diff --git a/docs/source/tutorial.rst b/docs/source/tutorial.rst index dc4da18..e8c489c 100644 --- a/docs/source/tutorial.rst +++ b/docs/source/tutorial.rst @@ -70,22 +70,27 @@ In Slick Reporting, you can do the same thing by creating a report view looking class TotalProductSales(ReportView): - - report_model = Sales - date_field = "doc_date" + report_model = SalesTransaction + date_field = "date" group_by = "product" columns = [ - "title", - SlickReportField.create(Sum, "quantity", "verbose_name": "Total quantity sold", "is_summable": False), - SlickReportField.create(Sum, "value", name="sum__value", "verbose_name": "Total Value sold $"), + "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 $"), ] chart_settings = [ Chart( "Total sold $", Chart.BAR, - data_source="value__sum", - title_source="title", + data_source=["sum__value"], + title_source=["name"], + ), + Chart( + "Total sold $ [PIE]", + Chart.PIE, + data_source=["sum__value"], + title_source=["name"], ), ] @@ -118,25 +123,24 @@ 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 .models import Sales - + from .models import SalesTransaction - class TotalProductSales(ReportView): - report_model = Sales - date_field = "doc_date" - group_by = "client__country" # notice the double underscore + class TotalProductSalesByCountry(ReportView): + report_model = SalesTransaction + date_field = "date" + group_by = "client__country" # notice the double underscore columns = [ - "country", - SlickReportField.create(Sum, "value", name="sum__value"), + "client__country", + SlickReportField.create(Sum, "value", name="sum__value", verbose_name="Total Value sold by country $"), ] chart_settings = [ Chart( - "Total sold $", - Chart.PIE, # A Pie Chart - data_source="value__sum", - title_source="country", + "Total sold by country $", + Chart.PIE, # A Pie Chart + data_source=["sum__value"], + title_source=["client__country"], ), ] @@ -149,6 +153,7 @@ 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 @@ -156,11 +161,13 @@ A time series report is a report that computes the data for each period of time. computation_method = Sum computation_field = "value" verbose_name = _("Sales Value") + name = "my_value_sum" + class MonthlyProductSales(ReportView): - report_model = Sales - date_field = "doc_date" + report_model = SalesTransaction + date_field = "date" group_by = "product" columns = ["name", "sku"] @@ -173,10 +180,16 @@ A time series report is a report that computes the data for each period of time. Chart( _("Total Sales Monthly"), Chart.PIE, - data_source=["value"], + data_source=["my_value_sum"], title_source=["name"], plot_total=True, ), + Chart( + _("Sales Monthly [Bar]"), + Chart.COLUMN, + data_source=["my_value_sum"], + title_source=["name"], + ), ] then again in your urls.py add the following: @@ -205,12 +218,11 @@ A crosstab report shows the relation between two or more variables. For example, .. code-block:: python - class ProductSalesPerCountry(ReportView): - report_model = Sales - date_field = "doc_date" + class ProductSalesPerCountryCrosstab(ReportView): + report_model = SalesTransaction + date_field = "date" group_by = "product" crosstab_field = "client__country" - crosstab_columns = [ SumValueComputationField, ] @@ -235,7 +247,7 @@ Then again in your urls.py add the following: urlpatterns = [ path( "product-sales-per-country/", - ProductSalesPerCountry.as_view(), + ProductSalesPerCountryCrosstab.as_view(), name="product-sales-per-country", ), ] @@ -247,25 +259,26 @@ A list report is a report that shows a list of records. For example, if you want .. code-block:: python - from slick_reporting.view import ListReportView + from slick_reporting.views import ListReportView class LastTenSales(ListReportView): - report_model = Sales - date_field = "doc_date" - group_by = "product" + report_model = SalesTransaction + report_title = "Last 10 sales" + date_field = "date" + filters = ["client"] columns = [ - "product__name", - "product__sku", - "doc_date", + "product", + "date", "quantity", "price", "value", ] - default_order_by = "-doc_date" + default_order_by = "-date" limit_records = 10 + Then again in your urls.py add the following: .. code-block:: python @@ -307,7 +320,6 @@ The interface is simple, only 3 mandatory methods to implement, The rest are man * ``get_end_date``: Mandatory, return the end date of the report. -* ``get_crispy_helper`` : return a crispy form helper to be used in rendering the form. (optional) For detailed information about the form, please check :ref:`filter_form` @@ -317,18 +329,19 @@ Example .. code-block:: python # forms.py + from django import forms + from django.db.models import Q from slick_reporting.forms import BaseReportForm - from crispy_forms.helper import FormHelper # A Normal form , Inheriting from BaseReportForm - class RequestLogForm(BaseReportForm, forms.Form): - - SECURE_CHOICES = ( + class TotalSalesFilterForm(BaseReportForm, forms.Form): + PRODUCT_SIZE_CHOICES = ( ("all", "All"), - ("secure", "Secure"), - ("non-secure", "Not Secure"), + ("big-only", "Big Only"), + ("small-only", "Small Only"), + ("medium-only", "Medium Only"), + ("all-except-extra-big", "All except extra Big"), ) - start_date = forms.DateField( required=False, label="Start Date", @@ -337,36 +350,25 @@ Example end_date = forms.DateField( required=False, label="End Date", widget=forms.DateInput(attrs={"type": "date"}) ) - secure = forms.ChoiceField( - choices=SECURE_CHOICES, required=False, label="Secure", initial="all" + product_size = forms.ChoiceField( + choices=PRODUCT_SIZE_CHOICES, required=False, label="Product Size", initial="all" ) - other_people_only = forms.BooleanField( - required=False, label="Show requests from other users only" - ) - def get_filters(self): # return the filters to be used in the report # Note: the use of Q filters and kwargs filters kw_filters = {} q_filters = [] - if self.cleaned_data["secure"] == "secure": - kw_filters["is_secure"] = True - elif self.cleaned_data["secure"] == "non-secure": - kw_filters["is_secure"] = False - if self.cleaned_data["other_people_only"]: - q_filters.append(~Q(user=self.request.user)) + if self.cleaned_data["product_size"] == "big-only": + kw_filters["product__size__in"] = ["extra_big", "big"] + elif self.cleaned_data["product_size"] == "small-only": + kw_filters["product__size__in"] = ["extra_small", "small"] + elif self.cleaned_data["product_size"] == "medium-only": + kw_filters["product__size__in"] = ["medium"] + elif self.cleaned_data["product_size"] == "all-except-extra-big": + q_filters.append(~Q(product__size__in=["extra_big", "big"])) return q_filters, kw_filters - def get_start_date(self): - return self.cleaned_data["start_date"] - - def get_end_date(self): - return self.cleaned_data["end_date"] - - def get_crispy_helper(self): - return FormHelper() - Recap ===== diff --git a/setup.cfg b/setup.cfg index d692cbe..9cc0353 100644 --- a/setup.cfg +++ b/setup.cfg @@ -45,7 +45,7 @@ packages = find: python_requires = >=3.6 install_requires = django>=2.2 - python-dateutil>=2.8.1 + python-dateutil>2.8.1 pytz simplejson django-crispy-forms diff --git a/slick_reporting/__init__.py b/slick_reporting/__init__.py index 949f6e3..12a81d1 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, 1) +VERSION = (1, 0, 2) -__version__ = "1.0.1" +__version__ = "1.0.2" diff --git a/slick_reporting/app_settings.py b/slick_reporting/app_settings.py index 58c2e04..208871f 100644 --- a/slick_reporting/app_settings.py +++ b/slick_reporting/app_settings.py @@ -29,19 +29,19 @@ def get_end_date(): SLICK_REPORTING_DEFAULT_END_DATE = lazy(get_end_date, datetime.datetime)() SLICK_REPORTING_FORM_MEDIA_DEFAULT = { - "css": { - "all": ( - "https://cdn.datatables.net/v/bs4/dt-1.10.20/datatables.min.css", - "https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.9.3/Chart.min.css", - ) - }, - "js": ( - "https://code.jquery.com/jquery-3.3.1.slim.min.js", - "https://cdn.datatables.net/v/bs4/dt-1.10.20/datatables.min.js", - "https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.9.3/Chart.bundle.min.js", - "https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.9.3/Chart.min.js", - "https://code.highcharts.com/highcharts.js", - ), + # "css": { + # "all": ( + # "https://cdn.datatables.net/v/bs4/dt-1.10.20/datatables.min.css", + # "https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.9.3/Chart.min.css", + # ) + # }, + # "js": ( + # "https://code.jquery.com/jquery-3.3.1.slim.min.js", + # "https://cdn.datatables.net/v/bs4/dt-1.10.20/datatables.min.js", + # "https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.9.3/Chart.bundle.min.js", + # "https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.9.3/Chart.min.js", + # "https://code.highcharts.com/highcharts.js", + # ), } SLICK_REPORTING_FORM_MEDIA = getattr( diff --git a/slick_reporting/fields.py b/slick_reporting/fields.py index e066480..54a6478 100644 --- a/slick_reporting/fields.py +++ b/slick_reporting/fields.py @@ -377,11 +377,12 @@ def get_crosstab_field_verbose_name(cls, model, id): @classmethod def get_time_series_field_verbose_name(cls, date_period, index, dates, pattern): """ - Get the name of the verbose name of a computaion field that's in a time_series. - should be a mix of the date period if the column an it's verbose name. + Get the name of the verbose name of a computation field that's in a time_series. + should be a mix of the date period of the column and it's verbose name. :param date_period: a tuple of (start_date, end_date) :param index: the index of the current field in the whole dates to be calculated :param dates a list of tuples representing the start and the end date + :param pattern it's the pattern name. monthly, daily, custom, ... :return: a verbose string """ dt_format = "%Y/%m/%d" diff --git a/slick_reporting/forms.py b/slick_reporting/forms.py index d91add6..cfb169a 100644 --- a/slick_reporting/forms.py +++ b/slick_reporting/forms.py @@ -1,11 +1,12 @@ from collections import OrderedDict +from crispy_forms.helper import FormHelper from django import forms from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ from . import app_settings -from .helpers import get_foreign_keys +from .helpers import get_foreign_keys, get_field_from_query_text TIME_SERIES_CHOICES = ( ("monthly", _("Monthly")), @@ -22,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 @@ -64,6 +65,13 @@ def get_crispy_helper( return helper +def get_choices_form_queryset_list(qs): + choices = [] + for row in qs: + choices.append((row, row)) + return choices + + class OrderByForm(forms.Form): order_by = forms.CharField(required=False) @@ -133,10 +141,15 @@ def get_time_series_pattern(self): ) def get_crispy_helper(self): - raise NotImplementedError( - "get_crispy_helper() must be implemented in subclass," - "should return a crispy helper object" - ) + # return a default helper + helper = FormHelper() + helper.form_class = "form-horizontal" + helper.label_class = "col-sm-2 col-md-2 col-lg-2" + helper.field_class = "col-sm-10 col-md-10 col-lg-10" + helper.form_tag = False + helper.disable_csrf = True + helper.render_unmentioned_fields = True + return helper class SlickReportForm(BaseReportForm): @@ -188,6 +201,8 @@ def crosstab_key_name(self): This is hook is to customize this naieve approach. :return: key: a string that should be in self.cleaned_data """ + if self.crosstab_field_klass: + return self.crosstab_field_klass.attname return f"{self.crosstab_model}_id" def get_crosstab_ids(self): @@ -195,11 +210,14 @@ def get_crosstab_ids(self): Get the crosstab ids so they can be sent to the report generator. :return: """ - if self.crosstab_model: - qs = self.cleaned_data.get(self.crosstab_key_name) - return [ - x for x in qs.values_list(self.crosstab_field_related_name, flat=True) - ] + 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) + ] + else: + return self.cleaned_data.get(self.crosstab_key_name) return [] def get_crosstab_compute_remainder(self): @@ -225,19 +243,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 @@ -256,6 +274,7 @@ def report_form_factory( :return: """ crosstab_field_related_name = "" + crosstab_field_klass = None foreign_key_widget_func = foreign_key_widget_func or _default_foreign_key_widget fkeys_filter_func = fkeys_filter_func or (lambda x: x) @@ -307,14 +326,33 @@ def report_form_factory( field_attrs["required"] = True fields[name] = f_field.formfield(**field_attrs) - if crosstab_model and display_compute_remainder: - fields["crosstab_compute_remainder"] = forms.BooleanField( - required=False, label=_("Display the crosstab remainder"), initial=True - ) - crosstab_field_klass = [ - x for x in model._meta.get_fields() if x.name == crosstab_model - ] - crosstab_field_related_name = crosstab_field_klass[0].to_fields[0] + if crosstab_model: + # todo Enhance, add tests , cover cases + # Crosstab is a foreign key on model + # crosstab is a Char field on model + # crosstab is a traversing fk field + # crosstab is a traversing Char / choice field + + if display_compute_remainder: + fields["crosstab_compute_remainder"] = forms.BooleanField( + required=False, label=_("Display the crosstab remainder"), initial=True + ) + + 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 + + if "__" in crosstab_model: # traversing field, it won't be added naturally to the form + 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) + bases = ( SlickReportForm, @@ -330,6 +368,7 @@ def report_form_factory( "crosstab_model": crosstab_model, "crosstab_display_compute_remainder": display_compute_remainder, "crosstab_field_related_name": crosstab_field_related_name, + "crosstab_field_klass": crosstab_field_klass, }, ) return new_form diff --git a/slick_reporting/generator.py b/slick_reporting/generator.py index c8f4d40..e6a432b 100644 --- a/slick_reporting/generator.py +++ b/slick_reporting/generator.py @@ -65,7 +65,7 @@ class ReportGeneratorAPI: group_by_custom_querysets = None """A List of querysets representing different group by options""" - group_by_custom_querysets_column_verbose_name = "" + group_by_custom_querysets_column_verbose_name = None columns = None """A list of column names. @@ -159,6 +159,7 @@ def __init__( kwargs_filters=None, group_by=None, group_by_custom_querysets=None, + group_by_custom_querysets_column_verbose_name=None, columns=None, time_series_pattern=None, time_series_columns=None, @@ -273,6 +274,7 @@ def __init__( 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.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 = ( @@ -399,6 +401,8 @@ def _apply_queryset_options(self, query, fields=None): if filters: query = query.filter(**filters) + if self.q_filters: + query = query.filter(*self.q_filters) if fields: return query.values(*fields) return query.values() @@ -594,7 +598,7 @@ def _default_format_row(self, row_obj): """ return row_obj - @classmethod + @staticmethod def check_columns( cls, columns, @@ -609,8 +613,10 @@ def check_columns( :param group_by: group by field if any :param report_model: the report model :param container_class: a class to search for custom columns attribute in, typically the ReportView + :param group_by_custom_querysets a list of group by custom queries Or None. :return: List of dict, each dict contains relevant data to the respective field in `columns` """ + group_by_model = None if group_by_custom_querysets: if "__index__" not in columns: @@ -625,7 +631,7 @@ def check_columns( ][0] except IndexError: raise ImproperlyConfigured( - f"Could not find {group_by} in {report_model}" + f"ReportView {cls}: Could not find the group_by field: `{group_by}` in report_model: `{report_model}`" ) if group_by_field.is_relation: group_by_model = group_by_field.related_model @@ -738,6 +744,7 @@ def check_columns( def _parse(self): self.parsed_columns = self.check_columns( + self, self.columns, self.group_by, self.report_model, @@ -866,7 +873,7 @@ def _get_time_series_dates(self, series=None, start_date=None, end_date=None): time_delta = datetime.timedelta(days=1) elif series == "weekly": time_delta = relativedelta(weeks=1) - elif series == "semimonthly": + elif series == "bi-weekly": time_delta = relativedelta(weeks=2) elif series == "monthly": time_delta = relativedelta(months=1) diff --git a/slick_reporting/static/slick_reporting/erp_framework.report_loader.js b/slick_reporting/static/slick_reporting/erp_framework.report_loader.js index 1f25fbe..748924e 100644 --- a/slick_reporting/static/slick_reporting/erp_framework.report_loader.js +++ b/slick_reporting/static/slick_reporting/erp_framework.report_loader.js @@ -125,14 +125,14 @@ 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) - - }); + // $('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, diff --git a/slick_reporting/templates/slick_reporting/js_resources.html b/slick_reporting/templates/slick_reporting/js_resources.html index 6858f1f..1279cc2 100644 --- a/slick_reporting/templates/slick_reporting/js_resources.html +++ b/slick_reporting/templates/slick_reporting/js_resources.html @@ -40,7 +40,7 @@ jQuery(document).ready(function () { $('.page-container').show(); $.erp_framework.focus_first(); - $.erp_framework.report_loader.initialize(); + {#$.erp_framework.report_loader.initialize();#} $('#changelist-form table').addClass('table table-hover table-striped table-condensed table-bordered') $("select").select2(); @@ -52,7 +52,7 @@ }); - $('.refreshReport').on('click', function (event) { + $('.refreshReport').not(".vanilla-btn-flag").on('click', function (event) { event.preventDefault(); let $elem = $('[data-report-widget]') $.erp_framework.report_loader.refreshReportWidget($elem) diff --git a/slick_reporting/templates/slick_reporting/simple_report.html b/slick_reporting/templates/slick_reporting/simple_report.html index a29ad02..74543c5 100644 --- a/slick_reporting/templates/slick_reporting/simple_report.html +++ b/slick_reporting/templates/slick_reporting/simple_report.html @@ -4,13 +4,19 @@ {% block content %} +
+ +

+ {{ title }} +

{% if form %}

Filters

{% crispy form crispy_helper %} - +
{% endif %} diff --git a/slick_reporting/views.py b/slick_reporting/views.py index 8e846d9..864bbb8 100644 --- a/slick_reporting/views.py +++ b/slick_reporting/views.py @@ -259,14 +259,17 @@ def get_form_kwargs(self): 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() - - crosstab_compute_remainder = ( - self.form.get_crosstab_compute_remainder() - if self.request.GET or self.request.POST - else self.crosstab_compute_remainder - ) + try: + crosstab_compute_remainder = ( + self.form.get_crosstab_compute_remainder() + if self.request.GET or self.request.POST + else self.crosstab_compute_remainder + ) + except NotImplementedError: + pass time_series_pattern = self.time_series_pattern if self.time_series_selector: @@ -290,8 +293,11 @@ def get_report_generator(self, queryset, for_print): swap_sign=self.swap_sign, columns=self.columns, group_by=self.group_by, + group_by_custom_querysets=self.group_by_custom_querysets, + group_by_custom_querysets_column_verbose_name=self.group_by_custom_querysets_column_verbose_name, time_series_pattern=time_series_pattern, time_series_columns=self.time_series_columns, + time_series_custom_dates=self.time_series_custom_dates, crosstab_field=self.crosstab_field, crosstab_ids=self.crosstab_ids, crosstab_columns=self.crosstab_columns, @@ -417,6 +423,7 @@ def __init_subclass__(cls) -> None: # sanity check, raises error if the columns or date fields is not set if cls.columns: cls.report_generator_class.check_columns( + cls, cls.columns, cls.group_by, cls.get_report_model(), diff --git a/tests/test_generator.py b/tests/test_generator.py index 3360ec9..6aade5b 100644 --- a/tests/test_generator.py +++ b/tests/test_generator.py @@ -126,7 +126,7 @@ def test_crosstab_time_series(self): columns = report.get_list_display_columns() time_series_columns = report.get_time_series_parsed_columns() expected_num_of_columns = ( - 2 * datetime.today().month + 2 * datetime.today().month ) # 2 client + 1 remainder * months since start of year self.assertEqual(len(time_series_columns), expected_num_of_columns, columns) @@ -379,6 +379,7 @@ def test_custom_group_by(self): ), 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"), @@ -386,6 +387,32 @@ def test_custom_group_by(self): ], date_field="doc_date", ) + data = report.get_report_data() + self.assertEqual(len(data), 2) + self.assertEqual(data[0]["sum__value"], 900) + self.assertEqual(data[1]["sum__value"], 1200) + self.assertIn("__index__", data[0].keys()) + 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.client3.pk]), + ], + columns=[ + "__index__", # assert that no issue if added manually , issue 68 + + SlickReportField.create(Sum, "value"), + "__total__", + ], + date_field="doc_date", + ) data = report.get_report_data() self.assertEqual(len(data), 2)