Repository: RamezIssac/django-slick-reporting Branch: develop Commit: 513651ddf558 Files: 121 Total size: 520.2 KB Directory structure: gitextract_vuqer0hb/ ├── .github/ │ └── workflows/ │ ├── django.yml │ └── release.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── CHANGELOG.md ├── CLAUDE.md ├── LICENSE.md ├── MANIFEST.in ├── README.rst ├── demo_proj/ │ ├── demo_app/ │ │ ├── __init__.py │ │ ├── admin.py │ │ ├── apps.py │ │ ├── forms.py │ │ ├── helpers.py │ │ ├── management/ │ │ │ └── commands/ │ │ │ └── create_entries.py │ │ ├── migrations/ │ │ │ ├── 0001_initial.py │ │ │ ├── 0002_salestransaction_price_salestransaction_quantity.py │ │ │ ├── 0003_product_category.py │ │ │ ├── 0004_client_country_product_sku.py │ │ │ ├── 0005_product_size.py │ │ │ ├── 0006_productcategory_remove_product_category_and_more.py │ │ │ ├── 0007_monthlysalessummary.py │ │ │ └── __init__.py │ │ ├── models.py │ │ ├── reports.py │ │ ├── templatetags/ │ │ │ ├── __init__.py │ │ │ └── slick_reporting_demo_tags.py │ │ ├── tests.py │ │ └── views.py │ ├── demo_proj/ │ │ ├── __init__.py │ │ ├── asgi.py │ │ ├── settings.py │ │ ├── urls.py │ │ └── wsgi.py │ ├── manage.py │ ├── requirements.txt │ └── templates/ │ ├── base.html │ ├── dashboard.html │ ├── demo/ │ │ └── apex_report.html │ ├── home.html │ ├── menu.html │ ├── slick_reporting/ │ │ ├── base.html │ │ └── report_form.html │ └── widget_template_with_pre.html ├── docs/ │ ├── requirements.txt │ └── source/ │ ├── concept.rst │ ├── conf.py │ ├── howto/ │ │ ├── customize_frontend.rst │ │ └── index.rst │ ├── index.rst │ ├── ref/ │ │ ├── computation_field.rst │ │ ├── dynamic_model.rst │ │ ├── index.rst │ │ ├── report_generator.rst │ │ ├── settings.rst │ │ └── view_options.rst │ ├── topics/ │ │ ├── charts.rst │ │ ├── computation_field.rst │ │ ├── crosstab_options.rst │ │ ├── dynamic_model.rst │ │ ├── exporting.rst │ │ ├── filter_form.rst │ │ ├── group_by_report.rst │ │ ├── index.rst │ │ ├── integrating_slick_reporting.rst │ │ ├── list_report_options.rst │ │ ├── pivot_report.rst │ │ ├── structure.rst │ │ ├── time_series_options.rst │ │ └── widgets.rst │ ├── tour.rst │ └── tutorial.rst ├── pyproject.toml ├── requirements.txt ├── runtests.py ├── scripts/ │ └── extract_changelog.py ├── setup.cfg ├── setup.py ├── slick_reporting/ │ ├── __init__.py │ ├── app_settings.py │ ├── apps.py │ ├── decorators.py │ ├── dynamic_model.py │ ├── fields.py │ ├── form_factory.py │ ├── forms.py │ ├── generator.py │ ├── helpers.py │ ├── locale/ │ │ ├── ar/ │ │ │ └── LC_MESSAGES/ │ │ │ └── django.po │ │ └── de/ │ │ └── LC_MESSAGES/ │ │ └── django.po │ ├── registry.py │ ├── static/ │ │ └── slick_reporting/ │ │ ├── slick_reporting.chartsjs.js │ │ ├── slick_reporting.datatable.js │ │ ├── slick_reporting.highchart.js │ │ ├── slick_reporting.js │ │ └── slick_reporting.report_loader.js │ ├── templates/ │ │ └── slick_reporting/ │ │ ├── base.html │ │ ├── js_resources.html │ │ ├── print_report.html │ │ ├── print_report_controls.html │ │ ├── print_report_footer.html │ │ ├── print_report_header.html │ │ ├── report.html │ │ ├── report_form.html │ │ └── widget_template.html │ ├── templatetags/ │ │ ├── __init__.py │ │ └── slick_reporting_tags.py │ └── views.py └── tests/ ├── __init__.py ├── models.py ├── report_generators.py ├── requirements.txt ├── settings.py ├── templates/ │ └── base.html ├── test_dynamic_model.py ├── test_generator.py ├── test_pivot_generator.py ├── tests.py ├── urls.py └── views.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/django.yml ================================================ name: Django CI on: push: branches: [ "develop", "master" ] pull_request: branches: [ "develop", "master" ] jobs: test: runs-on: ubuntu-latest strategy: max-parallel: 4 matrix: python-version: ["3.9", "3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip pip install -r tests/requirements.txt - name: Lint (ruff) run: | pip install ruff ruff check --line-length 120 slick_reporting/ - name: Run tests run: python runtests.py ================================================ FILE: .github/workflows/release.yml ================================================ name: Release on: push: tags: - 'v*' jobs: release: runs-on: ubuntu-latest environment: release permissions: contents: write # GitHub Release + push back to develop id-token: write # PyPI OIDC trusted publishing steps: - uses: actions/checkout@v4 with: fetch-depth: 0 # full history needed for the merge-back step - name: Set up Python uses: actions/setup-python@v5 with: python-version: '3.12' - name: Install dependencies run: | python -m pip install --upgrade pip pip install -r tests/requirements.txt - name: Run tests run: python runtests.py - name: Install build tools run: pip install build - name: Build package run: python -m build - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 - name: Extract release notes from CHANGELOG id: changelog run: | TAG=${GITHUB_REF#refs/tags/v} python scripts/extract_changelog.py "$TAG" > release_notes.md echo "tag=$TAG" >> "$GITHUB_OUTPUT" - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: body_path: release_notes.md files: dist/* - name: Notify demo server env: DEMO_WEBHOOK_URL: ${{ secrets.DEMO_WEBHOOK_URL }} DEMO_WEBHOOK_SECRET: ${{ secrets.DEMO_WEBHOOK_SECRET }} run: | curl -fsS -X POST "$DEMO_WEBHOOK_URL" \ -H "Authorization: Bearer $DEMO_WEBHOOK_SECRET" \ -H "Content-Type: application/json" \ -d '{"version": "${{ steps.changelog.outputs.tag }}"}' - name: Merge master back into develop run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git checkout develop git merge master --no-edit git push origin develop ================================================ FILE: .gitignore ================================================ # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ pip-wheel-metadata/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # PEP 582; used by e.g. github.com/David-OConnor/pyflow __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ fabfile.py ================================================ FILE: .pre-commit-config.yaml ================================================ repos: - repo: https://github.com/adamchainz/blacken-docs rev: "1.13.0" hooks: - id: blacken-docs additional_dependencies: - black==22.12.0 - repo: https://github.com/psf/black rev: 23.3.0 hooks: - id: black language_version: python3.9 - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. rev: v0.0.287 hooks: - id: ruff ================================================ FILE: .readthedocs.yaml ================================================ version: 2 build: os: "ubuntu-22.04" tools: python: "3.11" # Build from the docs/ directory with Sphinx sphinx: configuration: docs/source/conf.py # Explicitly set the version of Python and its requirements python: install: - requirements: docs/requirements.txt ================================================ FILE: CHANGELOG.md ================================================ # Changelog All notable changes to this project will be documented in this file. ## [1.4.0] - 2026-05-01 ### New Features - **Dynamic Model support** — generate reports from any database table without defining a Django model. New ``get_dynamic_model(table_name, database, schema)`` utility introspects a live table and returns a fully usable (unmanaged) Django model. Supports 20+ field types and PostgreSQL schemas (``schema`` parameter generates ``"schema"."table_name"`` as the db table). Models are cached after first introspection. - **``table_name`` attribute on ``ReportGenerator`` and ``ReportView``** — shorthand for using a dynamic model. Setting ``table_name = "my_table"`` is equivalent to setting ``report_model = get_dynamic_model("my_table")``. Column validation is deferred to request time for ``table_name``-based views so imports do not trigger database access. - **Pre-computed crosstab reports** — new ``crosstab_precomputed = True`` flag on ``ReportGenerator`` switches from live aggregation to reading existing column values and pivoting them. Use ``crosstab_columns`` to name the value columns and ``crosstab_field`` to identify the pivot field. Distinct values for ``crosstab_field`` are auto-discovered from the database if ``crosstab_ids`` is not set. Intended for materialized views, data-warehouse fact tables, and pre-aggregated ETL outputs. - **Print / HTML export** — new ``PrintHTMLExport`` class renders a clean, RTL-aware, print-ready HTML page and triggers ``window.print()`` automatically. Enabled by default on all ``ReportView`` subclasses via the new ``print_export_class`` attribute. The print export button opens in a new browser tab. Templates are split into ``print_report.html``, ``print_report_header.html``, ``print_report_footer.html``, and ``print_report_controls.html`` for easy overriding. - **Arabic (ar) and German (de) translations** added. ### Improvements - **Static asset path resolution** — template tags now consistently apply Django's ``static()`` to any relative URL in settings (jQuery URL, chart engine JS files, Font Awesome CSS URL). The ``slick_reporting_settings`` JSON block written to the page also contains fully resolved static URLs, ensuring correct paths with ``ManifestStaticFilesStorage`` or CDN-backed storages. - **Export action metadata** — ``get_export_actions()`` now includes a ``new_window`` flag per action; the JS export handler opens a new tab when the flag is set, used by the print export. - **Report form extracted** — the filter form markup is now in its own ``report_form.html`` template (included by ``report.html``) making it easier to override just the form section. - **Select2 removed from core assets** — Select2 was a demo-only dependency that made it into the default ``SLICK_REPORTING_SETTINGS["MEDIA"]`` JS/CSS lists and was auto-initialised in ``report_loader.js``. It is now removed from the library defaults; demo project configures it separately. - **``fkeys_filter_func_hook``** — new static method on ``ReportViewBase`` that receives the dict of detected foreign-key fields before the filter form is built. Override it to exclude or rename fields (e.g. remove internal FK columns such as ``polymorphic_ctype_id``). - **Chart error recovery** — chart rendering errors are now caught in ``report_loader.js`` and displayed as an inline message instead of silently breaking the rest of the page. ### Bug Fixes - Fix ``crosstab_compute_remainder`` class-level attribute being ignored; the value was always read from the request instead of falling back to the class default. ### Chart Engine Fixes - Update Highcharts wrapper to use the modern ``Highcharts.chart()`` API (v11+), replacing the removed jQuery plugin syntax. Switch default Highcharts CDN to jsDelivr (``cdn.jsdelivr.net/npm/highcharts@11``). - Fix Chart.js wrapper: corrected inverted ``is_time_series`` check; update to Chart.js v3/v4 API (``plugins.title``, ``plugins.tooltip``, ``scales.y`` / ``scales.x``). - Add crosstab support to the Chart.js wrapper. - Fix Chart.js ``area`` chart type to render as ``line`` with ``fill: true``. - Fix Chart.js pie chart on time-series reports: automatically enables ``plot_total``. - Fix Chart.js pie chart sizing with ``aspectRatio: 2``. - Fix Chart.js chart cache key to include the element xpath, preventing chart destruction when the same report appears multiple times on a dashboard. ## [1.3.1] - 2024-06-16 - Fix issue with Line Chart on highcharts engine - Reintroduce the stacking option on highcharts engine. - Fix issue with having different version of the same chart on the same page. - Enhanced the demo dashboard to show more capabilities regarding the charts. ## [1.3.0] - 2023-11-08 - Implement Slick reporting media override feature + docs - Add `Integrating reports into your Admin site` section to the docs - Group by and crosstab reports do not need date_field set anymore. Only time series do. - Fix in FirstBalance Computation field if no date is supplied - Add `REPORT_VIEW_ACCESS_FUNCTION` to control access to the report view ## [1.2.0] - 2023-10-10 - Add ``get_slick_reporting_media`` and ``get_charts_media`` templatetags - Add `get_group_by_custom_querysets` hook to ReportView - Enhance and document adding export options and customizing the builtin export to csv button - Enhance and document adding custom buttons to the report page - Enhance and document adding a new chart engine - Fix in SlickReportingListView - Move all css and js resources to be handled by `Media` governed by `settings.SLICK_REPORTING_SETTINGS` ## [1.1.1] - 2023-09-25 - Change settings to be a dict , adding support JQUERY_URL and FONT AWESOME customization #79 & #81 - Fix issue with chartjs not being loaded #80 - Remove `SLICK_REPORTING_FORM_MEDIA` ## [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. - 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 - Added missing js files ported from erp_framework package. - Document the need for "crispy_bootstrap4" in the docs and add it as a dependency in the setup. ## [1.0.0] - 2023-07-03 - Added crosstab_ids_custom_filters to allow custom filters on crosstab ids - 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. ## [0.9.0] - 2023-06-07 - Deprecated ``form_factory`` in favor of ``forms``, to be removed next version. - Deprecated `crosstab_model` in favor of ``crosstab_field``, to be removed next version. - Deprecated ``slick_reporting.view.SlickReportView`` and ``slick_reporting.view.SlickReportViewBase`` in favor of ``slick_reporting.view.ReportView`` and ``slick_reporting.view.BaseReportView``, to be removed next version. - Allowed cross tab on fields other than ForeignKey - Added support for start_date_field_name and end_date_field_name - Added support to crosstab on traversing fields - Added support for document types / debit and credit calculations - Added support for ordering via ``ReportView.default_order_by`` and/or passing the parameter ``order_by`` to the view - Added return of Ajax response in case of error and request is Ajax - Made it easy override to the search form. Create you own form and subclass BaseReportForm and implement the mandatory method(s). - Consolidated the needed resources in ``slick_reporting/js_resource.html`` template, so to use your own template you just need to include it. - Fixed an issue with report fields not respecting the queryset on the ReportView. - Fixed an issue if a foreign key have a custom `to_field` set either in ``group_by`` and/or `crosstab_field` . - Enhancing and adding to the documentation. - Black format the code and the documentation ## [0.8.0] - Breaking: [Only if you use Crosstab reports] renamed crosstab_compute_reminder to crosstab_compute_remainder - Breaking : [Only if you set the templates statics by hand] renamed slick_reporting to ra.hightchart.js and ra.chartjs.js to erp_framework.highchart.js and erp_framework.chartjs.js respectively - Fix an issue with Crosstab when there crosstab_compute_remainder = False ## [0.7.0] - Added SlickReportingListView: a Report Class to display content of the model (like a ModelAdmin ChangeList) - Added `show_time_series_selector` capability to SlickReportView allowing User to change the time series pattern from the UI. - Added ability to export to CSV from UI, using `ExportToStreamingCSV` & `ExportToCSV` - Now you can have a custom column defined on the SlickReportView (and not needing to customise the report generator). - You don't need to set date_field if you don't have calculations on the report - Easier customization of the crispy form layout - Enhance weekly time series default column name - Add `Chart` data class to hold chart data ## [0.6.8] - Add report_title to context - Enhance SearchForm to be easier to override. Still needs more enhancements. ## [0.6.7] - Fix issue with `ReportField` when it has a `requires` in time series and crosstab reports ## [0.6.6] - Now a method on a generator can be effectively used as column - Use correct model when traversing on group by ## [0.6.5] - Fix Issue with group_by field pointing to model with custom primary key Issue #58 ## [0.6.4] - Fix highchart cache to target the specific chart - Added initial and required to report_form_factory - Added base_q_filters and base_kwargs_filters to SlickReportField to control the base queryset - Add ability to customize ReportField on the fly - Adds `prevent_group_by` option to SlickReportField Will prevent group by calculation for this specific field, serves when you want to compute overall results. - Support reference to SlickReportField class directly in `requires` instead of its "registered" name. - Adds PercentageToBalance report field ## [0.6.3] - Change the deprecated in Django 4 `request.is_ajax` . ## [0.6.2] - Fix an issue with time series calculating first day of the month to be of the previous month #46 ## [0.6.1] - Fix Django 4 compatibility (@squio) ## [0.6.0] - Breaking [ONLY] if you have overridden ReportView.get_report_results() - Moved the collecting of total report data to the report generator to make easier low level usage. - Fixed an issue with Charts.js `get_row_data` - Added ChartsOption 'time_series_support',in both chart.js and highcharts - Fixed `SlickReportField.create` to use the issuing class not the vanilla one. ## [0.5.8] - Fix compatibility with Django 3.2 ## [0.5.7] - Add ability to refer to related fields in a group by report(@jrutila) ## [0.5.6] - Add exclude_field to report_form_factory (@gr4n0t4) - Added support for group by Many To Many field (@gr4n0t4) ## [0.5.5] - Add datepicker initialization function call (@squio) - Fixed an issue with default dates not being functional. ## [0.5.4] - Added missing prefix on integrity hash (@squio) ## [0.5.3] - Enhanced Field prepare flow - Add traversing for group_by - Allowed tests to run specific tests instead of the whole suit - Enhanced templates structure for easier override/customization ## [0.5.2] - Enhanced Time Series Plot total HighChart by accenting the categories - Enhanced the default verbose names of time series. - Expanding test coverage ## [0.5.1] - Allow for time series to operate on a non-group by report - Allow setting time series custom dates on ReportGenerator attr and init - Fix a bug with setting the queryset (but not the report model) on SlickReportView - Fixed an issue if GenericForeignKey is on the report model - Fixed an issue with Time series annual pattern ## [0.5.0] - 2020-12-11 - Created the demo site https://django-slick-reporting.com/ - Add support to group by date field - Add `format_row` hook to SlickReportingView - Add support for several chart engine per same report - Add `SLICK_REPORTING_FORM_MEDIA` &`SLICK_REPORTING_DEFAULT_CHARTS_ENGINE` setting. - Documenting SlickReportView response structure. - Fix issue with special column names `__time_series__` and `__crosstab__` - Fix issue with Crosstab reminder option. ## [0.4.2] - 2020-11-29 - Properly initialize Datepicker (#12 @squio) - Use previous date-range for initialization if it exists ## [0.4.1] - 2020-11-26 - Bring back calculateTotalOnObjectArray (#11) - Bypassing default ordering by when generating the report (#10) - Fix in dates in template and view ## [0.4.0] - 2020-11-24 [BREAKING] - Renamed `SampleReportView` to `SlickReportView` - Renamed `BaseReportField` to `SlickReportField` - Added `SlickReportViewBase` leaving sanity checks for the `SlickReportView` ## [0.3.0] - 2020-11-23 - Add Sanity checks against incorrect entries in columns or date_field - Add support to create ReportField on the fly in all report types - Enhance exception verbosity. - Removed `doc_date` field reference . ## [0.2.9] - 2020-10-22 ### Updated - Fixed an issue getting a db field verbose column name - Fixed an issue with the report demo page's filter button not working correctly. ## [0.2.8] - 2020-10-05 ### Updated - Fixed an error with ManyToOne Relation not being able to get its verbose name (@mswastik) ## [0.2.7] - 2020-07-24 ### Updates - Bring back crosstab capability - Rename `quan` to the more verbose `quantity` - Minor enhancements around templates ## [0.2.6] - 2020-06-06 ### Added - Adds `is_summable` option for ReportFields, and pass it to response - Add option to override a report fields while registering it. - Test ajax Request ### Updates and fixes - Fix a bug with time series adding one extra period. - Fix a bug with Crosstab data not passed to `report_form_factory` - Enhance Time series default column verbose name - testing: brought back ReportField after unregister test - Fix Pypi package not including statics. ## [0.2.5] - 2020-06-04 ### Added - Crosstab support - Chart title defaults to report_title - Enhance fields naming ## [0.2.4] - 2020-05-27 ### Added - Fix a naming issue with license (@iLoveTux) ## [0.2.3] - 2020-05-13 ### Added - Ability to create a ReportField on the fly. - Document SLICK_REPORTING_DEFAULT_START_DATE & SLICK_REPORTING_DEFAULT_START_DATE settings - Test Report Field Registry - Lift the assumption that a Report field name should start and end with "__". This is only a convention now. ## [0.2.2] - 2020-04-26 - Port Charting from [Ra Framework](https://github.com/ra-systems/RA) - Enhance ReportView HTML response ## [0.0.1] - 2020-04-24 ### Added - Ported from [Ra Framework](https://github.com/ra-systems/RA) ================================================ FILE: CLAUDE.md ================================================ # CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Project Django Slick Reporting is a Django library for generating analytical reports: simple aggregations, group-by, time-series, and crosstab (matrix) reports with built-in chart support (Highcharts, Chart.js). ## Commands ### Run all tests ```bash python runtests.py ``` ### Run a specific test ```bash python runtests.py tests.tests.TestClassName.test_method_name ``` ### Run tests with coverage ```bash coverage run --include=../* runtests.py && coverage html ``` ### Format & lint ```bash black --line-length 120 . ruff check --line-length 120 . ``` ## Architecture The library is layered as: **ReportView** (Django CBV) → **ReportGenerator** (computation engine) → **ComputationField** (calculation definitions) → **ReportFieldRegistry** (field lookup). ### Key modules in `slick_reporting/` - **generator.py** — Core engine. `ReportGenerator` handles group-by, time-series, and crosstab logic. `ListViewReportGenerator` handles ungrouped row-level reports. Entry point is `get_report_data()`. - **fields.py** — `ComputationField` base class and built-ins (`TotalReportField`, `BalanceReportField`, etc.). Fields declare `calculation_method` (e.g. `Sum`), `calculation_field`, and `requires` for dependency chaining. Use `ComputationField.create()` factory or subclass + `@report_field_register` decorator. - **views.py** — `ReportView` extends `FormView` with report generation, chart context, CSV export, and AJAX support. Access control via `test_func()`. - **forms.py** — `ReportForm` auto-generates filter forms from model ForeignKeys with crispy-forms/Bootstrap layout. `report_form_factory` builds forms dynamically. - **registry.py** — `field_registry` singleton. ComputationFields self-register to avoid naming collisions with factory-created fields. - **app_settings.py** — Defaults and settings loaded from Django's `SLICK_REPORTING_SETTINGS` dict. ### Charts Charts are configurable per report via `Chart` dataclass. Supported engines: Highcharts, Chart.js, and Apex Charts. New chart engines can be added by providing a JS integration file and registering it in settings. ### Report types Controlled by `ReportGenerator` configuration: - **Group-by**: set `group_by` field, get one row per distinct value - **Time-series**: set `time_series_pattern` (daily/weekly/monthly/yearly/custom), columns repeat per period - **Crosstab**: set `crosstab_field` + `crosstab_ids`, produces matrix layout - **Crosstab + Time-series**: can be combined for a matrix over time periods - **List view**: use `ListViewReportGenerator` for ungrouped row-level output ### Column duck typing Columns in `columns` list can be: model field names (str), traversing field names on the group-by model (e.g. `"client__contact__name"`), `ComputationField` subclasses, or special markers (`__total__`, `__balance__`, `__time_series__`, `__crosstab__`). ## Test structure Tests live in `tests/` with settings in `tests/settings.py` (SQLite, no migrations). Test models (`Product`, `Client`, `SimpleSales`, etc.) are defined in `tests/models.py`. `BaseTestData` in `tests/tests.py` creates fixture data across multiple dates for time-series testing. ## Code style - Black + Ruff, line length 120 - CI runs on Python 3.9, 3.10, 3.11 ================================================ FILE: LICENSE.md ================================================ BSD 3-Clause License Copyright (c) 2020, Ra Systems All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ================================================ FILE: MANIFEST.in ================================================ include LICENSE.md include README.md recursive-include slick_reporting/static * recursive-include slick_reporting/templates * recursive-include slick_reporting/locale * recursive-exclude tests/ * ================================================ FILE: README.rst ================================================ .. image:: https://img.shields.io/pypi/v/django-slick-reporting.svg :target: https://pypi.org/project/django-slick-reporting .. image:: https://img.shields.io/pypi/pyversions/django-slick-reporting.svg :target: https://pypi.org/project/django-slick-reporting .. image:: https://img.shields.io/readthedocs/django-slick-reporting :target: https://django-slick-reporting.readthedocs.io/ .. image:: https://api.travis-ci.com/ra-systems/django-slick-reporting.svg?branch=master :target: https://app.travis-ci.com/github/ra-systems/django-slick-reporting .. image:: https://img.shields.io/codecov/c/github/ra-systems/django-slick-reporting :target: https://codecov.io/gh/ra-systems/django-slick-reporting Django Slick Reporting ====================== A one stop reports engine with batteries included. Features -------- - Effortlessly create Simple, Grouped, Time series and Crosstab reports in a handful of code lines. - Create Chart(s) for your reports with a single line of code. - Create Custom complex Calculation. - Optimized for speed. - Easily extendable. Installation ------------ Use the package manager `pip `_ to install django-slick-reporting. .. code-block:: console pip install django-slick-reporting Usage ----- So we have a model `SalesTransaction` which contains typical data about a sale. We can extract different kinds of information for that model. 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 views.py from django.db.models import Sum from slick_reporting.views import ReportView, Chart from slick_reporting.fields import ComputationField from .models import MySalesItems class TotalProductSales(ReportView): report_model = SalesTransaction date_field = "date" group_by = "product" columns = [ "name", ComputationField.create( Sum, "quantity", verbose_name="Total quantity sold", is_summable=False ), ComputationField.create( Sum, "value", name="sum__value", verbose_name="Total Value sold $" ), ] 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"], ), ] # 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 :alt: Shipped in View Page 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 ComputationField from .models import SalesTransaction class MonthlyProductSales(ReportView): report_model = SalesTransaction date_field = "date" group_by = "product" columns = ["name", "sku"] time_series_pattern = "monthly" # or "yearly" , "weekly" , "daily" , others and custom patterns time_series_columns = [ ComputationField.create( Sum, "value", verbose_name=_("Sales Value"), name="value" ) # what will be calculated for each month ] chart_settings = [ Chart( _("Total Sales Monthly"), Chart.PIE, data_source=["value"], 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/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 ComputationField from .models import MySalesItems class MyCrosstabReport(ReportView): crosstab_field = "client" crosstab_ids = [1, 2, 3] crosstab_columns = [ ComputationField.create(Sum, "value", verbose_name=_("Value for")), ] crosstab_compute_remainder = True columns = [ "some_optional_field", # You can customize where the crosstab columns are displayed in relation to the other columns "__crosstab__", # This is the same as the Same as the calculation in the crosstab, but this one will be on the whole set. IE total value ComputationField.create(Sum, "value", verbose_name=_("Total Value")), ] .. image:: https://github.com/ra-systems/django-slick-reporting/blob/develop/docs/source/topics/_static/crosstab.png?raw=true :alt: Homepage :align: center Low level --------- The view is a wrapper over the `ReportGenerator` class, which is the core of the reporting engine. You can interact with the `ReportGenerator` using same syntax as used with the `ReportView` . .. code-block:: python from slick_reporting.generator import ReportGenerator from .models import MySalesModel class MyReport(ReportGenerator): report_model = MySalesModel group_by = "product" columns = ["title", "__total__"] # OR my_report = ReportGenerator( report_model=MySalesModel, group_by="product", columns=["title", "__total__"] ) my_report.get_report_data() # -> [{'title':'Product 1', '__total__: 56}, {'title':'Product 2', '__total__: 43}, ] 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 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 -r 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 Documentation ------------- Available on `Read The Docs `_ You can run documentation locally .. code-block:: console cd docs pip install -r requirements.txt sphinx-build -b html source build Road Ahead ---------- * Continue on enriching the demo project * Add the dashboard capabilities Running tests ----------------- Create a virtual environment (maybe with `virtual slick_reports_test`), activate it; Then , .. code-block:: console $ git clone git+git@github.com:ra-systems/django-slick-reporting.git $ cd tests $ python -m pip install -e .. $ python runtests.py # Or for Coverage report $ coverage run --include=../* runtests.py [-k] $ coverage html Support & Contributing ---------------------- Please consider star the project to keep an eye on it. Your PRs, reviews are most welcome and needed. We honor the well formulated `Django's guidelines `_ to serve as contribution guide here too. Authors -------- * **Ramez Ashraf** - *Initial work* - `RamezIssac `_ Cross Reference --------------- If you like this package, chances are you may like those packages too! `Django Tabular Permissions `_ Display Django permissions in a HTML table that is translatable and easy customized. `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 ⭐ ================================================ FILE: demo_proj/demo_app/__init__.py ================================================ ================================================ FILE: demo_proj/demo_app/admin.py ================================================ # Register your models here. ================================================ FILE: demo_proj/demo_app/apps.py ================================================ from django.apps import AppConfig class DemoAppConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "demo_app" ================================================ FILE: demo_proj/demo_app/forms.py ================================================ 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"] ================================================ FILE: demo_proj/demo_app/helpers.py ================================================ 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), ] PIVOT = [ ("precomputed-monthly-sales", reports.PreComputedMonthlySales), ("dynamic-model-sales-by-country", reports.DynamicModelSalesByCountry), ] OTHER = [ ("highcharts-examples", reports.HighChartExample), ("chartjs-examples", reports.ChartJSExample), ("apexcharts-examples", reports.ProductSalesApexChart), ("custom-export", reports.CustomExportReport), ("form-initial", reports.ReportWithFormInitial), ] def get_urls_patterns(): urls = [] for name, report in TUTORIAL + GROUP_BY + TIME_SERIES + CROSSTAB + PIVOT + OTHER: urls.append(path(f"{name}/", report.as_view(), name=name)) return urls ================================================ FILE: demo_proj/demo_app/management/commands/create_entries.py ================================================ 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, MonthlySalesSummary 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 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") 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}", # ) # Populate MonthlySalesSummary from the generated SalesTransaction data MonthlySalesSummary.objects.all().delete() from django.db.models.functions import TruncMonth from django.db.models import Sum monthly_data = ( SalesTransaction.objects.annotate(month=TruncMonth("date")) .values("product_id", "month") .annotate(total_sales=Sum("value"), total_quantity=Sum("quantity")) ) for row in monthly_data: MonthlySalesSummary.objects.create( product_id=row["product_id"], month=row["month"], total_sales=row["total_sales"], total_quantity=row["total_quantity"], ) # Create a raw SQL table for the dynamic model demo from django.db import connection with connection.cursor() as cursor: cursor.execute("DROP TABLE IF EXISTS regional_sales_summary") cursor.execute(""" CREATE TABLE regional_sales_summary ( id INTEGER PRIMARY KEY AUTOINCREMENT, product_name VARCHAR(100) NOT NULL, country VARCHAR(100) NOT NULL, total_sales DECIMAL(12, 2) NOT NULL DEFAULT 0, total_quantity DECIMAL(12, 2) NOT NULL DEFAULT 0 ) """) country_data = ( SalesTransaction.objects .values("product__name", "client__country") .annotate(total_sales=Sum("value"), total_quantity=Sum("quantity")) ) for row in country_data: cursor.execute( "INSERT INTO regional_sales_summary (product_name, country, total_sales, total_quantity) " "VALUES (%s, %s, %s, %s)", [row["product__name"], row["client__country"], row["total_sales"], row["total_quantity"]], ) self.stdout.write(self.style.SUCCESS("Entries Created Successfully")) ================================================ FILE: demo_proj/demo_app/migrations/0001_initial.py ================================================ # 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", }, ), ] ================================================ FILE: demo_proj/demo_app/migrations/0002_salestransaction_price_salestransaction_quantity.py ================================================ # 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, ), ] ================================================ FILE: demo_proj/demo_app/migrations/0003_product_category.py ================================================ # 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" ), ), ] ================================================ FILE: demo_proj/demo_app/migrations/0004_client_country_product_sku.py ================================================ # 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'), ), ] ================================================ FILE: demo_proj/demo_app/migrations/0005_product_size.py ================================================ # 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'), ), ] ================================================ FILE: demo_proj/demo_app/migrations/0006_productcategory_remove_product_category_and_more.py ================================================ # 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'), ), ] ================================================ FILE: demo_proj/demo_app/migrations/0007_monthlysalessummary.py ================================================ # Generated by Django 6.0.3 on 2026-04-23 15:27 import django.db.models.deletion from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ ('demo_app', '0006_productcategory_remove_product_category_and_more'), ] operations = [ migrations.CreateModel( name='MonthlySalesSummary', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('month', models.DateField(verbose_name='Month')), ('total_sales', models.DecimalField(decimal_places=2, max_digits=12, verbose_name='Total Sales')), ('total_quantity', models.DecimalField(decimal_places=2, max_digits=12, verbose_name='Total Quantity')), ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='demo_app.product', verbose_name='Product')), ], options={ 'verbose_name': 'Monthly Sales Summary', 'verbose_name_plural': 'Monthly Sales Summaries', 'unique_together': {('product', 'month')}, }, ), migrations.RunSQL(""" CREATE TABLE regional_sales_summary ( id INTEGER PRIMARY KEY AUTOINCREMENT, product_name VARCHAR(100) NOT NULL, country VARCHAR(100) NOT NULL, total_sales DECIMAL(12, 2) NOT NULL DEFAULT 0, total_quantity DECIMAL(12, 2) NOT NULL DEFAULT 0 ) """) ] ================================================ FILE: demo_proj/demo_app/migrations/__init__.py ================================================ ================================================ FILE: demo_proj/demo_app/models.py ================================================ 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, *args, **kwargs, ): self.value = self.price * self.quantity super().save(*args, **kwargs) class MonthlySalesSummary(models.Model): """Pre-aggregated monthly sales data for demonstrating crosstab_precomputed.""" product = models.ForeignKey(Product, on_delete=models.CASCADE, verbose_name=_("Product")) month = models.DateField(verbose_name=_("Month")) total_sales = models.DecimalField(max_digits=12, decimal_places=2, verbose_name=_("Total Sales")) total_quantity = models.DecimalField(max_digits=12, decimal_places=2, verbose_name=_("Total Quantity")) class Meta: verbose_name = _("Monthly Sales Summary") verbose_name_plural = _("Monthly Sales Summaries") unique_together = ("product", "month") def __str__(self): return f"{self.product} - {self.month}" ================================================ FILE: demo_proj/demo_app/reports.py ================================================ import datetime from django.db.models import Sum, Q from django.http import HttpResponse from django.utils.translation import gettext_lazy as _ from slick_reporting.fields import ComputationField from slick_reporting.views import ListReportView from slick_reporting.views import ReportView, Chart from .forms import TotalSalesFilterForm from .models import SalesTransaction, Product, MonthlySalesSummary class ProductSales(ReportView): report_title = _("Product Sales") report_description = _("Given a typical 'Sale Item' model, this report demonstrate a total of product sold. " "With a bar and a pie charts.") report_model = SalesTransaction date_field = "date" group_by = "product" columns = [ "name", ComputationField.create( method=Sum, field="value", name="value__sum", verbose_name="Total sold $", is_summable=True, ), ] # Charts chart_settings = [ Chart( "Total sold $", Chart.BAR, data_source=["value__sum"], title_source=["name"], ), ] class TotalProductSales(ReportView): report_title = _("Product Sales Quantity and Value [no auto load]") report_description = _("We compute the report over *two* fields `quantity and `value`." "Results only load after you press Filter") report_model = SalesTransaction date_field = "date" group_by = "product" columns = [ "name", ComputationField.create(Sum, "quantity", verbose_name="Total quantity sold", is_summable=False), ComputationField.create(Sum, "value", name="sum__value", verbose_name="Total Value sold $"), ] auto_load = False # Require the user to press the filter, useful if the report is resource demanding 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_title = _("Product Sales by Country") report_description = _("Group by using Django's double-underscore traversal (group_by='client__country').") report_model = SalesTransaction date_field = "date" group_by = "client__country" # notice the double underscore columns = [ "client__country", ComputationField.create(Sum, "value", name="sum__value", verbose_name="Total Value sold by country $"), ] chart_settings = [ Chart( "Total sold by country $", Chart.PIE, # A Pie Chart data_source=["sum__value"], title_source=["client__country"], ), ] class SumValueComputationField(ComputationField): calculation_method = Sum calculation_field = "value" verbose_name = _("Sales Value") name = "my_value_sum" class MonthlyProductSales(ReportView): report_title = _("Product Sales Monthly") report_description = _("Breaks product sales into one column per month using a Time Series, " "also demonstrates defining a reusable ComputationField.") 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_title = _("Product Sales Per Client Crosstab") report_description = _("A crosstab matrix with products as rows and clients as columns. " "The remainder column (crosstab_compute_remainder=True) " "captures sales not tied to a said clients.") report_model = SalesTransaction date_field = "date" group_by = "product" crosstab_field = "client" crosstab_columns = [ SumValueComputationField, ] crosstab_compute_remainder = True # Add a extra column to the report, capturing the value all other clients columns = [ "name", "sku", # a field that exists on the `Product` model "__crosstab__", SumValueComputationField, ] class ProductSalesPerCountryCrosstab(ReportView): report_title = _("Product Sales Per Country Crosstab") report_description = _("Demonstrate a crosstab/pivot on pre-set IDs (US, KW, EG, DE). " "The remainder column collects sales from all other countries.") 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, ] class LastTenSales(ListReportView): report_model = SalesTransaction report_title = "Last 10 sales" report_description = ("A list view (no aggregation) showing the ten most recent individual sale records." "Uses ListReportView for row-level data instead of grouped summaries.") date_field = "date" filters = ["product", "client", "date"] columns = [ "product__name", "client__name", "date", "quantity", "price", "value", ] default_order_by = "-date" limit_records = 10 class TotalProductSalesWithCustomForm(TotalProductSales): report_title = _("Total Product Sales with Custom Form") report_description = _("Demonstrates a custom Form (form_class) that adds a product-size filter " "alongside the standard date range filters.") form_class = TotalSalesFilterForm columns = [ "name", "size", ComputationField.create(Sum, "quantity", verbose_name="Total quantity sold", is_summable=False), ComputationField.create(Sum, "value", name="sum__value", verbose_name="Total Value sold $"), ] class GroupByReport(ReportView): report_model = SalesTransaction report_title = _("Group By Report") report_description = _("Groups sales by product with no date_field set. " "Shows that it's optional — omitting it gives all-time totals " "regardless of the date pickers.") # date_field = "date" group_by = "product" columns = [ "name", ComputationField.create( method=Sum, field="value", name="value__sum", verbose_name="Total sold $", is_summable=True, ), ] # Charts chart_settings = [ Chart( "Total sold $", Chart.BAR, data_source=["value__sum"], title_source=["name"], ), ] class GroupByTraversingFieldReport(GroupByReport): report_title = _("Group By Traversing Field") report_description = _( "Groups by a related model field using Django's double-underscore traversal (group_by='product__product_category'). No date filtering is applied.") group_by = "product__product_category" class GroupByCustomQueryset(ReportView): report_model = SalesTransaction report_title = _("Group By Custom Queryset") report_description = _("Replaces automatic group-by with three crafted querysets (big, small, medium)." "`format_row()` substitutes readable labels for the row's numeric index.") 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__", ComputationField.create(Sum, "value", verbose_name=_("Total Sold $"), name="value"), ] chart_settings = [ 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 NoGroupByReport(ReportView): report_model = SalesTransaction report_title = _("No-Group-By Report") report_description = _("Produces a single summary row for the whole dataset with no grouping." "Useful when you need a grand total over a date range rather than a breakdown.") 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_description = _( "Groups clients as rows and generates one column per month across the date range. The time_series_columns list defines what is computed for each period.") report_model = SalesTransaction group_by = "client" date_field = "date" # options are : "daily", "weekly", "bi-weekly", "monthly", "quarterly", "semiannually", "annually" and "custom" time_series_pattern = "monthly" # These columns will be calculated for each period in the time series. time_series_columns = [ ComputationField.create(Sum, "value", verbose_name=_("Sales For ")), ] columns = [ "name", # placeholder for the generated time series columns "__time_series__", # This is the same as the time_series_columns, but this one will be on the whole set ComputationField.create(Sum, "value", verbose_name=_("Total Sales")), ] chart_settings = [ Chart( "Client Sales", Chart.BAR, data_source=["sum__value"], title_source=["name"], ), Chart( "Total Sales [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") report_description = _("Adds a pattern selector (daily / weekly / bi-weekly / monthly) to the filter form." "users can switch time granularity without changing report code.") time_series_selector = True time_series_selector_choices = ( ("daily", _("Daily")), ("weekly", _("Weekly")), ("bi-weekly", _("Bi-Weekly")), ("monthly", _("Monthly")), ) time_series_selector_default = "bi-weekly" # The label for the time series selector time_series_selector_label = _("Period Pattern") # Allow the user to select an empty time series, in which case no time series will be applied to the report. time_series_selector_allow_empty = True def get_current_year(): return datetime.datetime.now().year class TimeSeriesReportWithCustomDates(TimeSeriesReport): report_title = _("Time Series Report With Custom Dates") report_description = _("Demonstrates a 'custom' time_series_pattern" "here: first 10 days of Jan, Feb and Mar. " "Useful for non-standard or irregular periods.") 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 TimeSeriesReportWithCustomGroupByQueryset(ReportView): report_title = _("Time Series Report") report_description = _("Combines custom querysets (US clients vs. RS+DE clients) with time series. " "Each queryset becomes a named row in the resulting matrix.") report_model = SalesTransaction date_field = "date" group_by_custom_querysets = ( SalesTransaction.objects.filter(client__country="US"), SalesTransaction.objects.filter(client__country__in=["RS", "DE"]), ) time_series_pattern = "monthly" time_series_columns = [ ComputationField.create(Sum, "value", verbose_name=_("Sales For ")), ] columns = [ "__index__", # placeholder for the generated time series columns "__time_series__", # This is the same as the time_series_columns, but this one will be on the whole set ComputationField.create(Sum, "value", verbose_name=_("Total Sales")), ] chart_settings = [ Chart( "Client Sales", Chart.BAR, data_source=["sum__value"], title_source=["__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 with custom verbose names # Similar to `ComputationField.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") report_description = _( "Extends custom date ranges with get_time_series_field_verbose_name() " "to give each period column a human-readable heading like 'First 10 days sales 1-2024'.") time_series_columns = [ SumOfFieldValue, # Use our newly created ComputationField 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` computation field title_source=["name"], ), Chart( "Total Sales [Pie]", Chart.PIE, data_source=["sum_of_value"], title_source=["name"], plot_total=True, ), ] class TimeSeriesWithoutGroupBy(ReportView): report_title = _("Time Series without a group by") report_description = _("A time series with no group_by: " "the entire dataset becomes one summary row split into pattern (here monthly) columns.") 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_description = _("A basic crosstab: clients as rows, products as columns. " "Each cell holds the sales value for that client–product combination, " "with a total column on the right.") report_model = SalesTransaction group_by = "client" # date_field = "date" columns = [ "name", # You can customize where the crosstab columns are displayed in relation to the other columns "__crosstab__", # This is 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")), ] crosstab_field = "product" crosstab_columns = [ ComputationField.create(Sum, "value", verbose_name=_("Value")), ] class CrosstabWithTraversingField(CrosstabReport): report_title = _("Cross tab Report With Traversing Field") report_description = _("Uses a traversed field (product__size) as the crosstab axis.") crosstab_field = "product__size" class CrosstabWithIds(CrosstabReport): report_title = _("Cross tab Report With Pre-set Ids") report_description = _("Pre-sets the crosstab column IDs to the first and last product via get_crosstab_ids(). " "Useful when you want a known set of columns resolved at request time.") def get_crosstab_ids(self): return [Product.objects.first().pk, Product.objects.last().pk] class CrosstabWithIdsCustomFilter(CrosstabReport): report_title = _("Crosstab with Custom Filters") report_description = _("Replaces per-ID filters with two arbitrary Q-object filters (big vs 'not' big)." "Demonstrates flexibility in breaking down crosstab columns ") 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") report_description = _("Demonstrates how to customize the verbose name" "Here, in each column header, we show 'Sales for Widget A' instead of the raw PK .") crosstab_columns = [CustomCrossTabTotalField] class CustomCrossTabTotalPerSize(CustomCrossTabTotalField): @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" @classmethod def get_time_series_field_verbose_name(cls, date_period, index, dates, pattern): 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") report_description = _("Combines Q-object filters with custom verbose names, " "labelling columns 'Big and Extra Big' and 'All other sizes'.") crosstab_columns = [CustomCrossTabTotalPerSize] class CrossTabWithTimeSeries(CrossTabReportWithCustomVerboseNameCustomFilter): report_title = _("Crosstab with time series") report_description = _("Layers a monthly time series on top of the crosstab, producing columns for each filter × period combination. " "Demonstrates the most complex report configuration available.") date_field = "date" time_series_pattern = "monthly" crosstab_columns = [CustomCrossTabTotalPerSize] columns = ["name", "__time_series__"] class ChartJSExample(TimeSeriesReport): report_title = _("ChartJS Examples ") report_description = _("The same time-series data visualised with Chart.js. " "Switching chart engines requires only setting" "chart_engine='chartsjs' on the report class.") chart_engine = "chartsjs" 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 [Line total]", Chart.LINE, data_source=["sum__value"], title_source=["name"], plot_total=True, ), ] class HighChartExample(TimeSeriesReport): chart_engine = "highcharts" report_title = _("Highcharts Examples ") report_description = _("Renders the same time-series data with all supported Highcharts chart types." ": column, bar, line, area, pie — including stacked and plot-total variants.") chart_settings = [ Chart("Columns", Chart.COLUMN, data_source=["sum__value"], title_source=["name"]), Chart( "Stacking Columns", Chart.COLUMN, data_source=["sum__value"], title_source=["name"], stacking=True, ), Chart( "Totals Column", Chart.COLUMN, data_source=["sum__value"], title_source=["name"], plot_total=True, ), Chart( "Total Stacking Column", Chart.COLUMN, data_source=["sum__value"], title_source=["name"], plot_total=True, stacking=True, ), Chart( "Bar", Chart.BAR, data_source=["sum__value"], title_source=["name"], ), Chart( "Totals Bar", Chart.BAR, data_source=["sum__value"], title_source=["name"], plot_total=True ), Chart( "Line Chart", Chart.LINE, data_source=["sum__value"], title_source=["name"], # plot_total=True, ), Chart( "Total Line chart", Chart.LINE, data_source=["sum__value"], title_source=["name"], plot_total=True, ), Chart( "Pie: Total Sales", Chart.PIE, data_source=["sum__value"], title_source=["name"], plot_total=True, ), Chart( "Area: Client Sales", Chart.AREA, data_source=["sum__value"], title_source=["name"], ), ] class ProductSalesApexChart(ReportView): report_title = _("Product Sales Apex Charts") report_description = _( "Demonstrates the ApexCharts engine with a custom template and " "a custom JS entry point (displayChartCustomEntryPoint) for fully bespoke chart initialisation.") report_model = SalesTransaction date_field = "date" group_by = "product" chart_engine = "apexcharts" template_name = "demo/apex_report.html" columns = [ "name", ComputationField.create( method=Sum, field="value", name="value__sum", verbose_name="Total sold $", is_summable=True, ), ] chart_settings = [ Chart( "Total sold $", type="pie", data_source=["value__sum"], title_source=["name"], ), Chart( "Total sold $", type="bar", data_source=["value__sum"], title_source=["name"], ), Chart( "A custom Entry Point $", type="bar", data_source=["value__sum"], title_source=["name"], entryPoint="displayChartCustomEntryPoint", # a custom entry point to control the chart ), ] class CustomExportReport(GroupByReport): report_title = _("Custom Export Report") report_description = _("Demonstrates adding custom action (here export_pdf) action alongside the built-ins" "Also shows how to customize buttons label and css class") export_actions = ["export_pdf"] def export_pdf(self, report_data): return HttpResponse(f"Dummy PDF Exported \n {report_data}") export_pdf.title = _("Export PDF") # The label for the export action button export_pdf.css_class = "btn btn-secondary" # the button classes def export_csv(self, report_data): return super().export_csv(report_data) export_csv.title = _("My Custom CSV export Title") export_csv.css_class = "btn btn-primary" class ReportWithFormInitial(ReportView): report_title = _("Report With Form Initial") report_description = _("Pre-populates the client filter with the first and last client using get_initial(). " "Shows how to set dynamic default filter values based on live data.") report_model = SalesTransaction date_field = "date" group_by = "product" columns = [ "name", ComputationField.create( method=Sum, field="value", name="value__sum", verbose_name="Total sold $", is_summable=True, ), ] def get_initial(self): from .models import Client initial = super().get_initial() initial["client_id"] = [Client.objects.first().pk, Client.objects.last().pk] return initial class PreComputedMonthlySales(ReportView): report_title = _("Crosstab Precomputed: Monthly Sales") report_description = _("Uses crosstab_precomputed=True on a model whose rows are already aggregated. " "Columns are the distinct month values discovered at query time — no aggregation needed.") report_model = MonthlySalesSummary date_field = "month" group_by = "product" crosstab_field = "month" crosstab_precomputed = True # signals that data is already aggregated crosstab_columns = ["total_sales", "total_quantity"] # These fields are already computed/aggregated in database columns = ["name", "__crosstab__"] chart_settings = [ Chart( _("Monthly Sales by Product"), Chart.BAR, data_source=["total_sales"], title_source=["name"], ), Chart( _("Monthly Quantity by Product"), Chart.LINE, data_source=["total_quantity"], title_source=["name"], ), ] class DynamicModelSalesByCountry(ReportView): report_title = _("Raw SQL Table / Dynamic Model Sales by Country") report_description = _("Here we're acting on raw SQL table (table_name='regional_sales_summary'). " "No Django model is involved — the schema is introspected at runtime." "Demonstrating it with Pre-computed crosstab, but we can use it with any type of reports") table_name = "regional_sales_summary" group_by = "product_name" crosstab_field = "country" crosstab_columns = ["total_sales", "total_quantity"] crosstab_precomputed = True columns = ["product_name", "__crosstab__"] chart_settings = [ Chart( _("Sales by Country"), Chart.BAR, data_source=["total_sales"], title_source=["product_name"], ), Chart( _("Sales by Country [Pie]"), Chart.PIE, data_source=["total_sales"], title_source=["product_name"], ), ] ================================================ FILE: demo_proj/demo_app/templatetags/__init__.py ================================================ ================================================ FILE: demo_proj/demo_app/templatetags/slick_reporting_demo_tags.py ================================================ import inspect 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, PIVOT 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 elif section == "pivot": to_use = PIVOT 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 def get_report_source(report): try: return inspect.getsource(report.__class__) except (OSError, TypeError): return "# Source code not available" @register.simple_tag def get_report_class_label(report): cls = report.__class__ return f"{cls.__module__}.{cls.__name__}" @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 "" ================================================ FILE: demo_proj/demo_app/tests.py ================================================ import datetime from django.contrib.auth import get_user_model from django.db import connection from django.test import TestCase from django.utils import timezone from . import helpers from .models import Client, MonthlySalesSummary, Product, ProductCategory, SalesTransaction ALL_REPORTS = helpers.TUTORIAL + helpers.GROUP_BY + helpers.TIME_SERIES + helpers.CROSSTAB + helpers.PIVOT + helpers.OTHER class DemoSanityTests(TestCase): @classmethod def setUpTestData(cls): cls.superuser = get_user_model().objects.create_superuser("admin", "admin@example.com", "password") category = ProductCategory.objects.create(name="Electronics") cls.p1 = Product.objects.create(name="Widget A", product_category=category, size="medium") cls.p2 = Product.objects.create(name="Widget B", product_category=category, size="big") cls.p3 = Product.objects.create(name="Widget C", product_category=category, size="small") cls.c1 = Client.objects.create(name="Client US", country="US") cls.c2 = Client.objects.create(name="Client DE", country="DE") cls.c3 = Client.objects.create(name="Client KW", country="KW") pairs = [ (cls.p1, cls.c1), (cls.p2, cls.c2), (cls.p1, cls.c3), (cls.p3, cls.c1), (cls.p2, cls.c2), (cls.p3, cls.c3), ] for i, (product, client) in enumerate(pairs): SalesTransaction.objects.create( number=f"INV-{i + 1:04d}", date=timezone.make_aware(datetime.datetime(2024, (i % 12) + 1, 15)), client=client, product=product, quantity=10, price=100, ) for product in [cls.p1, cls.p2, cls.p3]: for month in range(1, 4): MonthlySalesSummary.objects.create( product=product, month=datetime.date(2024, month, 1), total_sales=1000, total_quantity=10, ) with connection.cursor() as cursor: for row in [ ("Widget A", "US", 5000, 50), ("Widget B", "DE", 3000, 30), ("Widget C", "KW", 2000, 20), ]: cursor.execute( "INSERT INTO regional_sales_summary (product_name, country, total_sales, total_quantity)" " VALUES (%s, %s, %s, %s)", row, ) def setUp(self): self.client.force_login(self.superuser) def test_all_pages_load(self): for url in ["/", "/dashboard/"]: with self.subTest(url=url): self.assertEqual(self.client.get(url).status_code, 200) for name, _ in ALL_REPORTS: with self.subTest(name=name): response = self.client.get(f"/{name}/") self.assertEqual(response.status_code, 200) def test_all_report_data_endpoints(self): for name, _ in ALL_REPORTS: with self.subTest(name=name): response = self.client.get( f"/{name}/", data={"start_date": "2024-01-01", "end_date": "2024-12-31"}, HTTP_X_REQUESTED_WITH="XMLHttpRequest", ) self.assertEqual(response.status_code, 200) self.assertIn("data", response.json()) def test_precomputed_crosstab_fk_group_by_returns_data(self): """Regression: precomputed crosstab with FK group_by was returning empty rows. The generator was building main_queryset as .values("product_id") so each obj only had {"product_id": N}. _get_record_data then looked up obj["id"] which was None, causing every group key to resolve to "None" and miss the precomputed dict. Fix: fetch the related model objects (same as non-precomputed FK path). """ response = self.client.get( "/precomputed-monthly-sales/", data={"start_date": "2024-01-01", "end_date": "2024-12-31"}, HTTP_X_REQUESTED_WITH="XMLHttpRequest", ) self.assertEqual(response.status_code, 200) data = response.json()["data"] self.assertEqual(len(data), 3, "Expected one row per product") product_names = {row["name"] for row in data} self.assertEqual(product_names, {"Widget A", "Widget B", "Widget C"}) for row in data: crosstab_values = [v for k, v in row.items() if k not in ("name",) and v != ""] self.assertTrue( any(v not in (0, "0", None) for v in crosstab_values), f"All crosstab values are zero/empty for {row['name']} — group key lookup is broken", ) ================================================ FILE: demo_proj/demo_app/views.py ================================================ from django.views.generic import TemplateView # Create your views here. class HomeView(TemplateView): template_name = "home.html" class Dashboard(TemplateView): template_name = "dashboard.html" ================================================ FILE: demo_proj/demo_proj/__init__.py ================================================ ================================================ FILE: demo_proj/demo_proj/asgi.py ================================================ """ 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() ================================================ FILE: demo_proj/demo_proj/settings.py ================================================ """ 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/ """ import os 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 = os.getenv("DEBUG", "True").lower() in ("true", "1", "yes") _allowed_hosts = os.getenv("ALLOWED_HOSTS", "") ALLOWED_HOSTS = _allowed_hosts.split(",") if _allowed_hosts else ["*"] # 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_bootstrap5", "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": [os.path.join(BASE_DIR, "templates")], "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 = "bootstrap5" CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5" SLICK_REPORTING_DEFAULT_CHARTS_ENGINE = "highcharts" SLICK_REPORTING_SETTINGS = { "CHARTS": { "apexcharts": { "entryPoint": "DisplayApexPieChart", "js": ("https://cdn.jsdelivr.net/npm/apexcharts", "slick_reporting/slick_reporting.chartsjs.js"), "css": {"all": ("https://cdn.jsdelivr.net/npm/apexcharts/dist/apexcharts.min.css",)}, }, }, } STATIC_ROOT = os.getenv("STATIC_ROOT", BASE_DIR / "collected_static") MEDIA_ROOT = os.getenv("MEDIA_ROOT", str(BASE_DIR / "media")) ================================================ FILE: demo_proj/demo_proj/urls.py ================================================ """ 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 import views from demo_app import helpers urlpatterns = helpers.get_urls_patterns() + [ path("", views.HomeView.as_view(), name="home"), path("dashboard/", views.Dashboard.as_view(), name="dashboard"), path("admin/", admin.site.urls), ] ================================================ FILE: demo_proj/demo_proj/wsgi.py ================================================ """ 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, sys from django.core.wsgi import get_wsgi_application 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() ================================================ FILE: demo_proj/manage.py ================================================ #!/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() ================================================ FILE: demo_proj/requirements.txt ================================================ django>=4.2 python-dateutil>=2.8.1 simplejson django-crispy-forms crispy-bootstrap5 ================================================ FILE: demo_proj/templates/base.html ================================================ {% block meta_page_title %}{{ report_title }}{% endblock %} {# #}
{% block content %} {% endblock %}
{# #} {# #} {% block extrajs %} {% endblock %} ================================================ FILE: demo_proj/templates/dashboard.html ================================================ {% extends "base.html" %} {% load slick_reporting_tags %} {% block page_title %} Dashboard {% endblock %} {% block meta_page_title %} Dashboard {% endblock %} {% block content %}
{% get_widget_from_url url_name="product-sales" %}
{% get_widget_from_url url_name="total-product-sales-by-country" 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="monthly-product-sales" chart_id=1 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 %} {% block extrajs %} {% include "slick_reporting/js_resources.html" %} {# make sure to have the js_resources added to the dashboard page #} {% get_charts_media "all" %} {# make sure to add all charts needed media to the dashboard page. #} {# "all" loads all registered chart engines. If a CDN is blocked, it may break the page. #} {# You can also pass the specific chart_settings from a report view to load only what's needed. #} {% endblock %} ================================================ FILE: demo_proj/templates/demo/apex_report.html ================================================ {% extends "slick_reporting/report.html" %} {% load slick_reporting_tags %} {% block content %} {{ block.super }} {% endblock %} {% block extrajs %} {{ block.super }} {% endblock %} ================================================ FILE: demo_proj/templates/home.html ================================================ {% extends "base.html" %} {% block content %}

Welcome to Django Slick Reporting

The Reporting Engine for Django.

{# 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 %} ================================================ FILE: demo_proj/templates/menu.html ================================================ {% load slick_reporting_demo_tags %} ================================================ FILE: demo_proj/templates/slick_reporting/base.html ================================================ {% extends "base.html" %} {% block meta_page_title %} {{ report_title }}{% endblock %} {% block page_title %} {{ report_title }} {% endblock %} {% block page_subtitle %}{% if report.report_description %}

{{ report.report_description }}

{% endif %}{% endblock %} {% block extrajs %} {{ block.super }} {% include "slick_reporting/js_resources.html" %} {% endblock %} ================================================ FILE: demo_proj/templates/slick_reporting/report_form.html ================================================ {% load i18n crispy_forms_tags slick_reporting_demo_tags %}

{% translate "Filters" %}

{% if form and crispy_helper %} {% crispy form crispy_helper %} {% else %} {% crispy form %} {% endif %}
{% get_report_source report as source_code %} {% get_report_class_label report as class_label %} ================================================ FILE: demo_proj/templates/widget_template_with_pre.html ================================================ {% extends "slick_reporting/widget_template.html" %} {% block widget_content %}

    
{% endblock %} ================================================ FILE: docs/requirements.txt ================================================ -r ../requirements.txt crispy_bootstrap4 sphinx sphinx_rtd_theme==1.3.0 readthedocs-sphinx-search==0.3.1 ================================================ FILE: docs/source/concept.rst ================================================ .. _structure: Welcome to Django Slick Reporting documentation! ================================================== Django Slick Reporting a reporting engine allowing you to create and chart different kind of analytics from your model in a breeze. Demo site --------- If you haven't yet, please check https://django-slick-reporting.com for a quick walk-though with live code examples.. :ref:`Tutorial ` -------------------------- The tutorial will guide you to what is slick reporting, what kind of reports it can do for you and how to use it in your project. :ref:`Topic Guides ` ---------------------------- Discuss each type of report main structures you can create with Django Slick Reporting and their options. * :ref:`Group By report `: Similar to what we'd do with a GROUP BY sql statement. We group by a field and do some kind of calculations over the grouped records. * :ref:`time_series`: A step further, where the calculations are computed for time periods (day, week, month, custom etc). * :ref:`crosstab_reports`: Where the results shows the relationship between two or more variables. It's a table that shows the distribution of one variable in rows and another in columns. * :ref:`list_reports`: Similar to a django admin's changelist, it's a direct view of the report model records * And other topics like how to customize the form, and extend the exporting options. :ref:`Reference ` ---------------------------- Detailed information about main on Django Slick Reporting's main components #. :ref:`Settings `: The settings you can use to customize the behavior of Django Slick Reporting. #. :ref:`Report View `: A ``FormView`` CBV subclass with reporting capabilities allowing you to create different types of reports in the view. It provide a default :ref:`Filter Form ` to filter the report on. It mimics the Generator API interface, so knowing one is enough to work with the other. #. :ref:`Generator `: Responsible for generating report and orchestrating and calculating the computation fields values and mapping them to the results. It has an intuitive API that allows you to define the report structure and the computation fields to be calculated. #. :ref:`Computation Field `: a calculation unit,like a Sum or a Count of a certain field. Computation field class set how the calculation should be done. ComputationFields can also depend on each other. #. Charting JS helpers: Highcharts and Charts js helpers libraries to plot the data generated. so you can create the chart in 1 line in the view ================================================ FILE: docs/source/conf.py ================================================ # Configuration file for the Sphinx documentation builder. # # This file only contains a selection of the most common options. For a full # list see the documentation: # https://www.sphinx-doc.org/en/master/usage/configuration.html # -- Path setup -------------------------------------------------------------- # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # import os import sys import django sys.path.insert(0, os.path.abspath("../../")) os.environ["DJANGO_SETTINGS_MODULE"] = "tests.settings" django.setup() # -- Project information ----------------------------------------------------- project = "Django Slick Reporting" copyright = "2020, Ramez Ashraf" author = "Ramez Ashraf" master_doc = "index" # The full version, including alpha/beta/rc tags release = "0.6.8" # -- General configuration --------------------------------------------------- # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. autosummary_generate = True autoclass_content = "class" extensions = [ "sphinx.ext.viewcode", "sphinx.ext.autodoc", "sphinx.ext.autosummary", ] # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. exclude_patterns = [] # -- Options for HTML output ------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # html_theme = "sphinx_rtd_theme" # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ["_static"] ================================================ FILE: docs/source/howto/customize_frontend.rst ================================================ Charting and Front End Customization ===================================== The ajax response structure --------------------------- Understanding how the response is structured is imperative in order to customize how the report is displayed on the front end Let's have a look .. code-block:: python # Ajax response or `report_results` template context variable. response = { # the report slug, defaults to the class name all lower "report_slug": "", # a list of objects representing the actual results of the report "data": [ { "name": "Product 1", "quantity__sum": "1774", "value__sum": "8758", "field_x": "value_x", }, { "name": "Product 2", "quantity__sum": "1878", "value__sum": "3000", "field_x": "value_x", }, # etc ..... ], # A list explaining the columns/keys in the data results. # ie: len(response.columns) == len(response.data[i].keys()) # It contains needed information about verbose name , if summable and hints about the data type. "columns": [ { "name": "name", "computation_field": "", "verbose_name": "Name", "visible": True, "type": "CharField", "is_summable": False, }, { "name": "quantity__sum", "computation_field": "", "verbose_name": "Quantities Sold", "visible": True, "type": "number", "is_summable": True, }, { "name": "value__sum", "computation_field": "", "verbose_name": "Value $", "visible": True, "type": "number", "is_summable": True, }, ], # Contains information about the report as whole if it's time series or a a crosstab # And what's the actual and verbose names of the time series or crosstab specific columns. "metadata": { "time_series_pattern": "", "time_series_column_names": [], "time_series_column_verbose_names": [], "crosstab_model": "", "crosstab_column_names": [], "crosstab_column_verbose_names": [], }, # A mirror of the set charts_settings on the ReportView # ``ReportView`` populates the id and the `engine_name' if not set "chart_settings": [ { "type": "pie", "engine_name": "highcharts", "data_source": ["quantity__sum"], "title_source": ["name"], "title": "Pie Chart (Quantities)", "id": "pie-0", }, { "type": "bar", "engine_name": "chartsjs", "data_source": ["value__sum"], "title_source": ["name"], "title": "Column Chart (Values)", "id": "bar-1", }, ], } The ajax response structure --------------------------- Understanding how the response is structured is imperative in order to customize how the report is displayed on the front end Let's have a look .. code-block:: python # Ajax response or `report_results` template context variable. response = { "report_slug": "", # the report slug, defaults to the class name all lower "data": [], # a list of objects representing the actual results of the report "columns": [], # A list explaining the columns/keys in the data results. # ie: len(response.columns) == len(response.data[i].keys()) # A List of objects. each object contain field needed information like verbose name , if summable and hints about the data type. "metadata": {}, # Contains information about the report as whole if it's time series or a a crosstab # And what's the actual and verbose names of the time series or crosstab specific columns. "chart_settings": [], # a list of objects mirror of the set charts_settings } ================================================ FILE: docs/source/howto/index.rst ================================================ .. _how_to: ======= How To ======= In this section we will go over some of the frequent tasks you will need to do when using ReportView. Customize the form ================== The filter form is automatically generated for convenience but you can override it and add your own Form. The system expect that the form used with the ``ReportView`` to implement the ``slick_reporting.forms.BaseReportForm`` interface. The interface is simple, only 3 mandatory methods to implement, The rest are mandatory only if you are working with a crosstab report or a time series report. #. get_filters: return the filters to be used in the report in a tuple The first element is a list of Q filters (is any) The second element is a dict of filters to be used in the queryset These filters will be passed to the report_model.objects.filter(*q_filters, **kw_filters) #. get_start_date: return the start date to be used in the report #. get_end_date: return the end date to be used in the report .. code-block:: python # forms.py from slick_reporting.forms import BaseReportForm class RequestFilterForm(BaseReportForm, forms.Form): SECURE_CHOICES = ( ("all", "All"), ("secure", "Secure"), ("non-secure", "Not Secure"), ) start_date = forms.DateField( required=False, label="Start Date", widget=forms.DateInput(attrs={"type": "date"}), ) end_date = forms.DateField( required=False, label="End Date", widget=forms.DateInput(attrs={"type": "date"}) ) secure = forms.ChoiceField( choices=SECURE_CHOICES, required=False, label="Secure", initial="all" ) method = forms.CharField(required=False, label="Method") other_people_only = forms.BooleanField( required=False, label="Show requests from other People Only" ) def __init__(self, request=None, *args, **kwargs): self.request = request super().__init__(*args, **kwargs) self.fields["start_date"].initial = datetime.date.today() self.fields["end_date"].initial = datetime.date.today() def get_filters(self): q_filters = [] kw_filters = {} if self.cleaned_data["secure"] == "secure": kw_filters["is_secure"] = True elif self.cleaned_data["secure"] == "non-secure": kw_filters["is_secure"] = False if self.cleaned_data["method"]: kw_filters["method"] = self.cleaned_data["method"] if self.cleaned_data["response"]: kw_filters["response"] = self.cleaned_data["response"] if self.cleaned_data["other_people_only"]: q_filters.append(~Q(user=self.request.user)) return q_filters, kw_filters def get_start_date(self): return self.cleaned_data["start_date"] def get_end_date(self): return self.cleaned_data["end_date"] For a complete reference of the ``BaseReportForm`` interface, check :ref:`filter_form_customization` Use the report view in our own template --------------------------------------- To use the report template with your own project templates, you simply need to override the ``slick_reporting/base.html`` template to make it extends your own base template You only need to have a ``{% block content %}`` in your base template to be able to use the report template and a ``{% block extrajs %}`` block to add the javascript implementation. The example below assumes you have a ``base.html`` template in your project templates folder and have a content block and a project_extrajs block in it. .. code-block:: html {% extends "base.html" %} {% load static %} {% block content %} {% endblock %} {% block project_extrajs %} {% include "slick_reporting/js_resources.html" %} {% block extrajs %} {% endblock %} {% endblock %} Work with tree data & Nested categories --------------------------------------- Change the report structure in response to User input ----------------------------------------------------- Create your own Chart Engine ----------------------------- Create a Custom ComputationField and reuse it --------------------------------------------- Add a new chart engine ---------------------- Add an exporting option ----------------------- Work with categorical data -------------------------- How to create a custom ComputationField --------------------------------------- create custom columns --------------------- format numbers in the datatable custom group by custom time series periods custom crosstab reports .. toctree:: :maxdepth: 2 :caption: Topics: :titlesonly: customize_frontend ================================================ FILE: docs/source/index.rst ================================================ Django Slick Reporting ====================== **Django Slick Reporting** a reporting engine allowing you to create & display diverse analytics. Batteries like a ready to use View and Highcharts & Charts.js integration are included. * Create group by , crosstab , timeseries, crosstab in timeseries and list reports in handful line with intuitive syntax * Highcharts & Charts.js integration ready to use with the shipped in View, easily extendable to use with your own charts. * Export to CSV * Easily extendable to add your own computation fields, Installation ------------ To install django-slick-reporting with pip .. code-block:: bash pip install django-slick-reporting Usage ----- #. Add ``"slick_reporting", "crispy_forms", "crispy_bootstrap4",`` to ``INSTALLED_APPS``. #. Add ``CRISPY_TEMPLATE_PACK = "bootstrap4"`` to your ``settings.py`` #. Execute `python manage.py collectstatic` so the JS helpers are collected and served. Quickstart ---------- You can start by using ``ReportView`` which is a subclass of ``django.views.generic.FormView`` .. code-block:: python # in views.py from slick_reporting.views import ReportView, Chart from slick_reporting.fields import ComputationField from .models import MySalesItems from django.db.models import Sum class ProductSales(ReportView): report_model = MySalesItems date_field = "date_placed" group_by = "product" columns = [ "title", ComputationField.create( method=Sum, field="value", name="value__sum", verbose_name="Total sold $" ), ] # Charts chart_settings = [ Chart( "Total sold $", Chart.BAR, data_source=["value__sum"], title_source=["title"], ), ] # in urls.py from django.urls import path from .views import ProductSales urlpatterns = [ path("product-sales/", ProductSales.as_view(), name="product-sales"), ] Demo site ---------- https://django-slick-reporting.com is a quick walk-though with live code examples Next step :ref:`tutorial` .. toctree:: :maxdepth: 2 :caption: Contents: concept tutorial topics/index ref/index Indices and tables ================== * :ref:`genindex` * :ref:`modindex` * :ref:`search` ================================================ FILE: docs/source/ref/computation_field.rst ================================================ .. _computation_field_ref: ComputationField API -------------------- .. autoclass:: slick_reporting.fields.ComputationField .. autoattribute:: name .. autoattribute:: calculation_field .. autoattribute:: calculation_method .. autoattribute:: verbose_name .. autoattribute:: requires .. autoattribute:: type .. rubric:: Below are some data passed by the `ReportGenerator`, for extra manipulation, you can change them .. autoattribute:: report_model .. autoattribute:: group_by .. autoattribute:: plus_side_q .. autoattribute:: minus_side_q .. rubric:: You can customize those methods for maximum control where you can do pretty much whatever you want. .. automethod:: prepare .. automethod:: resolve .. automethod:: get_dependency_value ================================================ FILE: docs/source/ref/dynamic_model.rst ================================================ .. _dynamic_model_ref: ============= Dynamic Model ============= .. module:: slick_reporting.dynamic_model ``get_dynamic_model`` --------------------- .. function:: get_dynamic_model(table_name, database="default", schema=None) Introspect a database table and return a Django model class for it. The returned model has ``managed = False`` and is fully compatible with the Django ORM. Results are cached so repeated calls return the same class. :param table_name: The database table name to introspect. :type table_name: str :param database: The database alias from ``DATABASES`` setting. :type database: str :param schema: Optional schema name (PostgreSQL). The schema must be in the connection's ``search_path``. When provided, ``db_table`` is set to ``"schema"."table_name"``. :type schema: str or None :returns: A Django model class mapped to the table. :rtype: type (subclass of ``django.db.models.Model``) :raises ValueError: If the table does not exist. ``table_name`` attribute ------------------------ Both ``ReportGenerator`` and ``ReportView`` accept a ``table_name`` parameter. When set (and ``report_model`` is not), ``get_dynamic_model`` is called automatically. .. code-block:: python class MyReport(ReportView): table_name = "legacy_sales" date_field = "sale_date" group_by = "region" columns = [...] # Or via ReportGenerator init report = ReportGenerator(table_name="legacy_sales", ...) See :ref:`dynamic_model_topic` for full usage guide and examples. ================================================ FILE: docs/source/ref/index.rst ================================================ .. _reference: Reference =========== Below are links to the reference documentation for the various components of the Django slick reporting . .. toctree:: :maxdepth: 2 :caption: Components: settings view_options computation_field report_generator dynamic_model ================================================ FILE: docs/source/ref/report_generator.rst ================================================ .. _report_generator: Report Generator API ==================== The main class responsible generating the report and managing the flow ReportGenerator --------------- .. autoclass:: slick_reporting.generator.ReportGenerator .. rubric:: Below are the basic needed attrs .. autoattribute:: report_model .. autoattribute:: queryset .. autoattribute:: date_field .. autoattribute:: columns .. autoattribute:: group_by .. rubric:: Below are the needed attrs and methods for time series manipulation .. autoattribute:: time_series_pattern .. autoattribute:: time_series_columns .. automethod:: get_custom_time_series_dates .. automethod:: get_time_series_field_verbose_name .. rubric:: Below are the needed attrs and methods for crosstab manipulation .. autoattribute:: crosstab_field .. autoattribute:: crosstab_columns .. autoattribute:: crosstab_ids .. autoattribute:: crosstab_compute_remainder .. automethod:: get_crosstab_field_verbose_name .. rubric:: Below are the magical attrs .. autoattribute:: limit_records .. autoattribute:: swap_sign .. autoattribute:: field_registry_class ================================================ FILE: docs/source/ref/settings.rst ================================================ .. _settings: Settings ======== .. note:: Settings are changed in version 1.1.1 to being a dictionary instead of individual variables. Variables will continue to work till next major release. Below are the default settings for django-slick-reporting. You can override them in your settings file. .. code-block:: python SLICK_REPORTING_SETTINGS = { "JQUERY_URL": "https://code.jquery.com/jquery-3.7.0.min.js", "DEFAULT_START_DATE_TIME": datetime( datetime.now().year, 1, 1, 0, 0, 0, tzinfo=timezone.utc ), # Default: 1st Jan of current year "DEFAULT_END_DATE_TIME": datetime.datetime.today(), # Default to today "DEFAULT_CHARTS_ENGINE": SLICK_REPORTING_DEFAULT_CHARTS_ENGINE, "MEDIA": { "override": False, # set it to True to override the media files, # False will append the media files to the existing ones. "js": ( "https://cdn.jsdelivr.net/momentjs/latest/moment.min.js", "https://cdn.datatables.net/1.13.4/js/jquery.dataTables.min.js", "https://cdn.datatables.net/1.13.4/js/dataTables.bootstrap5.min.js", "slick_reporting/slick_reporting.js", "slick_reporting/slick_reporting.report_loader.js", "slick_reporting/slick_reporting.datatable.js", ), "css": { "all": ( "https://cdn.datatables.net/1.13.4/css/dataTables.bootstrap5.min.css", ) }, }, "FONT_AWESOME": { "CSS_URL": "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.3/css/all.min.css", "ICONS": { "pie": "fas fa-chart-pie", "bar": "fas fa-chart-bar", "line": "fas fa-chart-line", "area": "fas fa-chart-area", "column": "fas fa-chart-column", }, }, "CHARTS": { "highcharts": "$.slick_reporting.highcharts.displayChart", "chartjs": "$.slick_reporting.chartjs.displayChart", }, "MESSAGES": { "total": _("Total"), }, } * JQUERY_URL: Link to the jquery file, You can use set it to False and manage the jQuery addition to your liking * DEFAULT_START_DATE_TIME Default date time that would appear on the filter form in the start date * DEFAULT_END_DATE_TIME Default date time that would appear on the filter form in the end date * FONT_AWESOME: Font awesome is used to display the icon next to the chart title. You can override the following settings: 1. ``CSS_URL``: URL to the font-awesome css file 2. ``ICONS``: Icons used for different chart types. * CHARTS: The entry points for displaying charts on the front end. You can add your own chart engine by adding an entry to this dictionary. * MESSAGES: The strings used in the front end. You can override them here, it also gives a chance to set and translate them per your requirements. Old versions settings: 1. ``SLICK_REPORTING_DEFAULT_START_DATE``: Default: the beginning of the current year 2. ``SLICK_REPORTING_DEFAULT_END_DATE``: Default: the end of the current year. 3. ``SLICK_REPORTING_FORM_MEDIA``: Controls the media files required by the search form. Defaults is: .. code-block:: python SLICK_REPORTING_FORM_MEDIA = { "css": { "all": ( "https://cdn.datatables.net/v/bs4/dt-1.10.20/datatables.min.css", "https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.9.3/Chart.min.css", ) }, "js": ( "https://code.jquery.com/jquery-3.3.1.slim.min.js", "https://cdn.datatables.net/v/bs4/dt-1.10.20/datatables.min.js", "https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.9.3/Chart.bundle.min.js", "https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.9.3/Chart.min.js", "https://code.highcharts.com/highcharts.js", ), } 4. ``SLICK_REPORTING_DEFAULT_CHARTS_ENGINE``: Controls the default chart engine used. ================================================ FILE: docs/source/ref/view_options.rst ================================================ .. _report_view_options: ================ The Report View ================ Below is the list of options that can be used in the ReportView class. Core Options ============= report_model ------------ The model where the relevant data is stored, in more complex reports, it's usually a database view / materialized view. You can customize it at runtime via the ``get_report_model`` hook. .. code-block:: python class MyReportView(ReportView): def get_report_model(self): from my_app.models import MyReportModel return MyReportModel.objects.filter(some_field__isnull=False) queryset -------- The queryset to be used in the report, if not specified, it will default to ``report_model._default_manager.all()`` group_by -------- If the data in the report_model needs to be grouped by a field. It can be a foreign key, a text field / choice field on the report model or traversing. Example: Assuming we have the following SalesModel .. code-block:: python class SalesModel(models.Model): date = models.DateTimeField() notes = models.TextField(blank=True, null=True) client = models.ForeignKey( "client.Client", on_delete=models.PROTECT, verbose_name=_("Client") ) product = models.ForeignKey( "product.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) Our ReportView can have the following group_by options: .. code-block:: python from slick_reporting.views import ReportView class MyReport(ReportView): report_model = SalesModel group_by = "product" # a field on the model # OR # group_by = 'client__country' a traversing foreign key field # group_by = 'client__gender' a traversing choice field columns ------- Columns are a list of column names and to make it more flexible, you can pass a tuple of column name and options. The options are only `verbose_name` and `is_summable`. like this: .. code-block:: python class MyReport(ReportView): columns = [ "id", ("name", {"verbose_name": "My verbose name", "is_summable": False}), ] A column name can be any of the following: 1. A computation field 2. A field on the grouped by model 3. A callable on the view /or the generator 4. A Special ``__time_series__``, ``__crosstab__``, ``__index__`` Let's take them one by one: 1. A Computation Field. ~~~~~~~~~~~~~~~~~~~~~~~~~~ Added as a class or by its name. Example: .. code-block:: python from slick_reporting.fields import ComputationField, Sum from slick_reporting.registry import field_registry from slick_reporting.views import ReportView @field_registry.register class MyTotalReportField(ComputationField): name = "__some_special_name__" class MyReport(ReportView): columns = [ ComputationField.create(Sum, "value", verbose_name=_("Value"), name="value"), # a computation field created on the fly MyTotalReportField, # Added a a class "__some_special_name__", # added by name ] For more information: :ref:`computation_field` 2. Fields on the group by model ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Implying that the group_by is set to a field on the report_model. .. code-block:: python class MyReport(ReportView): report_model = SalesModel group_by = "client" columns = [ "name", # field that exists on the Client Model "date_of_birth", # field that exists on the Client Model "agent__name", # a traversing field from client model # ... ] # If the group_by is traversing then the available columns would be of the model at the end of the traversing class MyOtherReport(ReportView): report_model = MySales group_by = "client__agent" columns = [ "name", "country", # fields that exists on the Agent Model "contact__email", # A traversing field from the Agent model ] .. note:: If group_by is not set, columns can be only a calculation field. refer to the topic `no_group_by_topic` 3. A callable on the view ~~~~~~~~~~~~~~~~~~~~~~~~~~~ The callable should accept the following arguments :param obj: a dictionary of the current group_by row :param row: a the current row of the report. :return: the value to be displayed in the report .. code-block:: python class Report(ReportView): columns = [ "field_on_group_by_model", "group_by_model__traversing_field", "get_attribute", ComputationField.create(name="example")] def get_attribute(self, obj: dict, row: dict): # obj: a dictionary of the current group_by row # row: a the current row of the report. return f"{obj["field_on_group_by_model_2"]} - {row["group_by_model__traversing_field"]}" get_attribute.verbose_name = "My awesome title" 4. A Special ``__time_series__``, ``__crosstab__``, ``__index__`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ``__time_series__``: is used to control the position of the time series columns inside the report. ``__crosstab__``: is used to control the position of the crosstab columns inside the report. ``__index__``: is used to display the index of the report, it's usually used with the ``group_by_custom_querysets`` option. date_field ---------- The date field to be used in filtering and computing start_date_field_name --------------------- The name of the start date field, if not specified, it will default to what set in ``date_field`` end_date_field_name ------------------- The name of the end date field, if not specified, it will default to ``date_field`` chart_settings -------------- A list of Chart objects representing the charts you want to attach to the report. Example: .. code-block:: python class MyReport(ReportView): report_model = Request # .. chart_settings = [ Chart( title="Browsers", type=Chart.PIE, # or just string "bar" title_source=["user_agent"], data_source=["count__id"], plot_total=False, ), Chart( "Browsers Bar Chart", Chart.BAR, title_source=["user_agent"], data_source=["count__id"], plot_total=True, ), ] form_class ---------- The form you need to display to control the results. Default to an automatically generated form containing the start date, end date and all foreign keys on the model. For more information: `filter_form` excluded_fields ----------------- Fields to be excluded from the automatically generated form auto_load -------------- Control if the report should be loaded automatically on page load or not, default to ``True`` ``report_title`` ---------------- The title of the report to be displayed in the report page. ``report_title_context_key`` ---------------------------- The context key to be used to pass the report title to the template, default to ``report_title``. ``template_name`` ----------------- The template to be used to render the report, default to ``slick_reporting/report.html`` You can override this to customize the report look and feel. ``csv_export_class`` -------------------- Set the csv export class to be used to export the report, default to ``ExportToStreamingCSV`` ``report_generator_class`` -------------------------- Set the generator class to be used to generate the report, default to ``ReportGenerator`` ``default_order_by`` -------------------- A Default order by for the results. As you would expect, for DESC order: default_order_by (or order_by as a parameter) ='-field_name' .. note:: Ordering can also be controlled at run time by passing order_by='field_name' as a parameter to the view. ``limit_records`` ----------------- Limit the number of records to be displayed in the report, default to ``None`` (no limit) ``swap_sign`` -------------- Swap the sign of the values in the report, default to ``False`` Double Sided Calculations Options ================================== .. attribute:: ReportView.with_type Set if double sided calculations should be taken into account, default to ``False`` Read more about double sided calculations here https://django-erp-framework.readthedocs.io/en/latest/topics/doc_types.html .. attribute:: ReportView.doc_type_field_name Set the doc_type field name to be used in double sided calculations, default to ``doc_type`` .. attribute:: ReportView.doc_type_plus_list Set the doc_type plus list to be used in double sided calculations, default to ``None`` .. attribute:: ReportView.doc_type_minus_list Set the doc_type minus list to be used in double sided calculations, default to ``None`` Hooks and functions ==================== .. attribute:: ReportView.get_queryset() Override this function to return a custom queryset to be used in the report. .. attribute:: ReportView.get_report_title() Override this function to return a custom report title. .. attribute:: ReportView.ajax_render_to_response() Override this function to return a custom response for ajax requests. .. attribute:: ReportView.format_row() Override this function to return a custom row format. .. attribute:: ReportView.filter_results(data, for_print=False) Hook to Filter results, usable if you want to do actions on the data set based on computed data (like eliminate __balance__ = 0, etc) :param data: the data set , list of dictionaries :param for_print: if the data is being filtered for printing or not :return: the data set after filtering. .. attribute:: ReportView.get_form_crispy_helper() Override this function to return a custom crispy form helper for the report form. ================================================ FILE: docs/source/topics/charts.rst ================================================ Charts Customization ==================== Charts Configuration --------------------- ReportView ``charts_settings`` is a list of objects which each object represent a chart configurations. The chart configurations are: * title: the Chart title. Defaults to the `report_title`. * type: A string. Examples are pie, bar, line, etc ... * engine_name: A string, default to the ReportView ``chart_engine`` attribute, then to the ``SLICK_REPORTING_SETTINGS.DEFAULT_CHARTS_ENGINE``. * data_source: string, the field name containing the numbers we want to plot. * title_source: string, the field name containing labels of the data_source * plot_total: if True the chart will plot the total of the columns. Useful with time series and crosstab reports. * entryPoint: the javascript entry point to display the chart, the entryPoint function accepts the data, $elem and the chartSettings parameters. On front end, for each chart needed we pass the whole response to the relevant chart helper function and it handles the rest. Customizing the entryPoint for a chart -------------------------------------- Sometimes you want to display the chart differently, in this case, you can just change the entryPoint function. Example: .. code-block:: python class ProductSalesApexChart(ReportView): # .. template_name = "product_sales_report.html" chart_settings = [ # .. Chart( "Total sold $", type="bar", data_source=["value__sum"], title_source=["name"], entryPoint="displayChartCustomEntryPoint", # this is the new entryPoint ), ] Then in your template `product_sales_report.html` add the javascript function specified as the new entryPoint. .. code-block:: html+django {% extends "slick_reporting/report.html" %} {% load slick_reporting_tags %} {% block extra_js %} {{ block.super }} {% endblock %} Adding a new charting engine ---------------------------- In the following part we will add some Apex charts to the demo app to demonstrate how you can add your own charting engine to slick reporting. #. We need to add the new chart Engine to the settings. Note that the css and js are specified and handled like Django's ``Form.Media`` .. code-block:: python SLICK_REPORTING_SETTINGS = { "CHARTS": { "apexcharts": { "entryPoint": "DisplayApexPieChart", "js": ( "https://cdn.jsdelivr.net/npm/apexcharts", "js_file_for_apex_chart.js", # this file contains the entryPoint function and is responsible # for compiling the data and rendering the chart ), "css": { "all": "https://cdn.jsdelivr.net/npm/apexcharts/dist/apexcharts.min.css" }, } }, } #. Add the entry point function to the javascript file `js_file_for_apex_chart.js` in this example. It can look something like this: .. code-block:: javascript let chart = null; function DisplayApexPieChart(data, $elem, chartOptions) { // Where: // data: is the ajax response coming from server // $elem: is the jquery element where the chart should be rendered // chartOptions: is the relevant chart dictionary/object in your ReportView chart_settings let legendAndSeries = $.slick_reporting.chartsjs.getGroupByLabelAndSeries(data, chartOptions); // `getGroupByLabelAndSeries` is a helper function that will return an object with two keys: labels and series let options = {} if (chartOptions.type === "pie") { options = { series: legendAndSeries.series, chart: { type: "pie", height: 350 }, labels: legendAndSeries.labels, }; } else { options = { chart: { type: 'bar' }, series: [{ name: 'Sales', data: legendAndSeries.series }], xaxis: { categories: legendAndSeries.labels, } } } try { // destroy old chart, if any chart.destroy(); } catch (e) { // do nothing } chart = new ApexCharts($elem[0], options); chart.render(); } ================================================ FILE: docs/source/topics/computation_field.rst ================================================ .. _computation_field: Computation Field ================= ComputationFields are the basic unit in a report.they represent a number that is being computed. Computation Fields can be add to a report as a class, as you saw in other examples , or by name. Creating Computation Fields --------------------------- There are 3 ways you can create a Computation Field 1. Create a subclass of ComputationField and set the needed attributes and use it in the columns attribute of the ReportView 2. Use the `ComputationField.create()` method and pass the needed attributes and use it in the columns attribute of the ReportView 3. Use the `report_field_register` decorator to register a ComputationField subclass and use it by its name in the columns attribute of the ReportView .. code-block:: python from slick_reporting.fields import ComputationField from slick_reporting.decorators import report_field_register @report_field_register class TotalQTYReportField(ComputationField): name = "__total_quantity__" calculation_field = "quantity" # the field we want to compute on calculation_method = Sum # What method we want, default to Sum verbose_name = _("Total quantity") class ProductSales(ReportView): report_model = SalesTransaction # .. columns = [ # ... "__total_quantity__", # Use the ComputationField by its registered name TotalQTYReportField, # Use Computation Field as a class ComputationField.create( Sum, "quantity", name="__total_quantity__", verbose_name=_("Total quantity") ) # Using the ComputationField.create() method ] What happened here is that we: 1. Created a ComputationField subclass and gave it the needed attributes 2. Register it via ``report_field_register`` so it can be picked up by the framework. 3. Used it by name inside the columns attribute (or in time_series_columns, or in crosstab_columns) 4. Note that this is same as using the class directly in the columns , also the same as using `ComputationField.create()` Another example, adding and AVG to the field `price`: .. code-block:: python from django.db.models import Avg from slick_reporting.decorators import report_field_register @report_field_register class TotalQTYReportField(ComputationField): name = "__avg_price__" calculation_field = "price" calculation_method = Avg verbose_name = _("Avg. Price") class ProductSales(ReportView): # .. columns = [ "name", "__avg_price__", ] Using Value of a Computation Field within a another --------------------------------------------------- Sometime you want to stack values on top of each other. For example: Net revenue = Gross revenue - Discounts. .. code-block:: python class PercentageToTotalBalance(ComputationField): requires = [BalanceReportField] name = "__percent_to_total_balance__" verbose_name = _("%") calculation_method = Sum calculation_field = "value" prevent_group_by = True def resolve( self, prepared_results, required_computation_results: dict, current_pk, current_row=None, ) -> float: result = super().resolve( prepared_results, required_computation_results, current_pk, current_row ) return required_computation_results.get("__balance__") / result * 100 We need to override ``resolve`` to do the needed calculation. The ``required_computation_results`` is a dictionary of the results of the required fields, where the keys are the names. Note: 1. The ``requires`` attribute is a list of the required fields to be computed before this field. 2. The values of the ``requires`` fields are available in the ``required_computation_results`` dictionary. 3. In the example we used the ``prevent_group_by`` attribute. It's as the name sounds, it prevents the rows from being grouped for teh ComputationField giving us the result over the whole set. How it works ? -------------- When the `ReportGenerator` is initialized, it generates a list of the needed fields to be displayed and computed. Each computation field in the report is given the filters needed and asked to get all the results prepared. Then for each record, the ReportGenerator again asks each ComputationField to get the data it has for each record and map it back. Customizing the Calculation Flow -------------------------------- The results are prepared in 2 main stages 1. Preparation: Where you can get the whole result set for the report. Example: Sum of all the values in a model group by the products. 2. resolve: Where you get the value for each record. .. code-block:: python class MyCustomComputationField(ComputationField): name = "__custom_field__" def prepare( self, q_filters: list | object = None, kwargs_filters: dict = None, queryset=None, **kwargs ): # do all you calculation here for the whole set if any and return the prepared results pass def resolve( self, prepared_results, required_computation_results: dict, current_pk, current_row=None, ) -> float: # does the calculation for each record, return a value pass Bundled Report Fields --------------------- As this project came form an ERP background, there are some bundled report fields that you can use out of the box. * __total__ : `Sum` of the field named `value` * __total_quantity__ : `Sum` of the field named `quantity` * __fb__ : First Balance, Sum of the field `value` on the start date (or period in case of time series) * __balance__: Compound Sum of the field `value`. IE: the sum of the field `value` on end date. * __credit__: Sum of field Value for the minus_list * __debit__: Sum of the field value for the plus list * __percent_to_total_balance__: Percent of the field value to the balance What is the difference between total and balance fields ? Total: Sum of the value for the period Balance: Sum of the value for the period + all the previous periods. Example: You have a client who buys 10 in Jan., 12 in Feb. and 13 in March: * `__total__` will return 10 in Jan, 12 in Feb and 13 in March. * `__balance__` will return 10 in Jan, 22 in Feb and 35 in March ================================================ FILE: docs/source/topics/crosstab_options.rst ================================================ .. _crosstab_reports: 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. General use case ---------------- Here is a general use case: .. code-block:: python from django.utils.translation import gettext_lazy as _ from django.db.models import Sum from slick_reporting.views import ReportView 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")), ] 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 .. code-block:: python class CrosstabWithTraversingField(CrosstabReport): crosstab_field = "product__size" 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 CrosstabWithIds(CrosstabReport): def get_crosstab_ids(self): return [Product.objects.first().pk, Product.objects.last().pk] Customizing the Crosstab Filter ------------------------------- 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``. 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 Customizing the verbose name of the crosstab columns ---------------------------------------------------- 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. Example 1: On a "regular" crosstab report .. code-block:: python 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): crosstab_columns = [CustomCrossTabTotalField] Example 2: On the ``crosstab_ids_custom_filters`` one .. code-block:: python class CustomCrossTabTotalField2(CustomCrossTabTotalField): @classmethod def get_crosstab_field_verbose_name(cls, model, id): if id == 0: return f"{cls.verbose_name} Big and Extra Big" return f"{cls.verbose_name} all other sizes" class CrossTabReportWithCustomVerboseNameCustomFilter(CrosstabWithIdsCustomFilter): crosstab_columns = [CustomCrossTabTotalField2] Example ------- .. image:: _static/crosstab.png :width: 800 :alt: crosstab :align: center 1. The Group By. In this example, it is the product field. 2. The Crosstab. In this example, it is the client field. crosstab_ids were set to client 1 and client 2 3. The remainder. In this example, it is the rest of the clients. crosstab_compute_remainder was set to True ================================================ FILE: docs/source/topics/dynamic_model.rst ================================================ .. _dynamic_model_topic: ============== Dynamic Models ============== General use case ---------------- Sometimes you need to generate reports from database tables that don't have a corresponding Django model. This is common with: * Legacy database tables * Data warehouse / analytics tables * Tables managed by other systems or ETL pipelines * Temporary or staging tables Django Slick Reporting provides ``get_dynamic_model`` which introspects any database table and creates a real Django model at runtime. Since the result is a genuine Django model, all report features work natively: group by, time series, crosstab, aggregation, filtering, and charts. Basic usage ----------- Use the ``get_dynamic_model`` utility to create a model from an existing table: .. code-block:: python from slick_reporting.dynamic_model import get_dynamic_model # Introspect the table and get a Django model class SalesData = get_dynamic_model("legacy_sales") # Use it like any Django model SalesData.objects.all() SalesData.objects.filter(region="US").count() Then use it with ``ReportGenerator`` or ``ReportView`` as usual: .. code-block:: python from slick_reporting.views import ReportView, Chart from slick_reporting.fields import ComputationField from slick_reporting.dynamic_model import get_dynamic_model from django.db.models import Sum SalesData = get_dynamic_model("legacy_sales") class LegacySalesReport(ReportView): report_model = SalesData date_field = "sale_date" group_by = "product_id" columns = [ "product_id", ComputationField.create( method=Sum, field="amount", name="amount__sum", verbose_name="Total Sales", ), ] chart_settings = [ Chart( "Total Sales", Chart.BAR, data_source=["amount__sum"], title_source=["product_id"], ), ] Using ``table_name`` shortcut ----------------------------- Instead of calling ``get_dynamic_model`` yourself, you can set ``table_name`` directly on ``ReportView`` or pass it to ``ReportGenerator``: .. code-block:: python # On a view class LegacySalesReport(ReportView): table_name = "legacy_sales" date_field = "sale_date" group_by = "product_id" columns = [ "product_id", ComputationField.create( method=Sum, field="amount", name="amount__sum", verbose_name="Total Sales", ), ] .. code-block:: python # With ReportGenerator directly from slick_reporting.generator import ReportGenerator report = ReportGenerator( table_name="legacy_sales", date_field="sale_date", group_by="product_id", columns=[ "product_id", ComputationField.create(Sum, "amount", name="amount__sum", verbose_name="Total Sales"), ], start_date=start, end_date=end, ) data = report.get_report_data() When ``table_name`` is set and ``report_model`` is not, the dynamic model is created automatically. Using a different database -------------------------- If the table is in a non-default database, pass the ``database`` parameter: .. code-block:: python WarehouseData = get_dynamic_model("fact_sales", database="warehouse") This uses the database alias defined in your Django ``DATABASES`` setting. Working with database schemas ----------------------------- On PostgreSQL, tables can live in different schemas (e.g. ``analytics``, ``staging``). **Prerequisites:** The schema must be accessible via the connection's ``search_path`` so Django's introspection can find the table. You can configure this in your database settings: .. code-block:: python # settings.py DATABASES = { "default": { "ENGINE": "django.db.backends.postgresql", "NAME": "mydb", "OPTIONS": { "options": "-c search_path=analytics,public", }, } } Then pass the ``schema`` parameter so ORM queries reference the correct schema: .. code-block:: python SalesData = get_dynamic_model("sales", schema="analytics") This sets the model's ``db_table`` to ``"analytics"."sales"`` so all generated SQL uses the schema-qualified table name. **Schema support by database backend:** +----------------+-------------------------------------------------------------------+ | Backend | How schemas work | +================+===================================================================+ | PostgreSQL | Use ``schema`` parameter + ensure schema is in ``search_path`` | +----------------+-------------------------------------------------------------------+ | MySQL | Schemas are databases. Use the ``database`` parameter instead | +----------------+-------------------------------------------------------------------+ | SQLite | No schema concept. Just use ``table_name`` | +----------------+-------------------------------------------------------------------+ How it works ------------ ``get_dynamic_model`` performs the following steps: 1. Connects to the database and verifies the table exists. 2. Uses Django's ``connection.introspection`` to read column names, types, and constraints. 3. Maps each database column to the appropriate Django field (``IntegerField``, ``CharField``, ``DateField``, etc.). 4. Creates a Django model class at runtime with ``managed = False`` (no migrations needed). 5. Registers the model in Django's app registry so the ORM works fully. 6. Caches the result so subsequent calls for the same table return the same model class. Since the result is a standard Django model, all ``ReportGenerator`` features work without any special handling: ``group_by``, ``time_series_pattern``, ``crosstab_field``, ``ComputationField``, form generation, and chart rendering. Caching ------- Dynamic models are cached in memory. Calling ``get_dynamic_model("my_table")`` multiple times returns the same model class. The cache key includes the database alias and schema, so models for different databases or schemas are cached separately. Limitations ----------- * **No foreign key relationships.** Foreign key columns are introspected as plain integer or varchar fields. This means ``group_by`` traversal across relationships (e.g. ``product__category``) is not available. You can group by the FK column directly (e.g. ``product_id``). * **No automatic form filters for FK fields.** Since there are no FK relations, the auto-generated filter form will only contain date range fields. Supply a custom ``form_class`` if you need additional filters. * **Schema must be in search_path.** On PostgreSQL, Django's introspection only finds tables visible in the current ``search_path``. The schema must be configured at the connection level. * **Table structure changes require restart.** Because models are cached, if the table structure changes (columns added/removed), you need to restart the application or clear the cache. ================================================ FILE: docs/source/topics/exporting.rst ================================================ Exporting ========= Exporting to CSV ----------------- To trigger an export to CSV, just add ``?_export=csv`` to the url. This is performed by by the Export to CSV button in the default form. This will call the export_csv on the view class, engaging a `ExportToStreamingCSV` Having an `_export` parameter not implemented, ie the view class do not implement ``export_{parameter_name}``, will be ignored. Configuring the CSV export option --------------------------------- You can disable the CSV export option by setting the ``csv_export_class`` attribute to ``False`` on the view class. and you can override the function and its attributes to customize the button text .. code-block:: python class CustomExportReport(GroupByReport): report_title = _("Custom Export Report") def export_csv(self, report_data): return super().export_csv(report_data) export_csv.title = _("My Custom CSV export Title") export_csv.css_class = "btn btn-success" Adding an export option ----------------------- You can extend the functionality, say you want to export to pdf. Add a ``export_pdf`` method to the view class, accepting the report_data json response and return the response you want. This ``export_pdf` will be called automatically when url parameter contain ``?_export=pdf`` Example to add a pdf export option: .. code-block:: python class CustomExportReport(GroupByReport): report_title = _("Custom Export Report") export_actions = ["export_pdf"] def export_pdf(self, report_data): return HttpResponse(f"Dummy PDF Exported {report_data}") export_pdf.title = _("Export PDF") export_pdf.icon = "fa fa-file-pdf-o" export_pdf.css_class = "btn btn-primary" The export function should accept the report_data json response and return the response you want. ================================================ FILE: docs/source/topics/filter_form.rst ================================================ .. _filter_form: Customizing Filter Form ======================= The filter form is a form that is used to filter the data to be used in the report. The generated form ------------------- Behind the scene, The view calls ``slick_reporting.form_factory.report_form_factory`` in ``get_form_class`` method. ``report_form_factory`` is a helper method which generates a form containing start date and end date, as well as all foreign keys on the report_model. Changing the generated form API is still private, however, you can use your own form easily. Overriding the Form -------------------- The system expect that the form used with the ``ReportView`` to implement the ``slick_reporting.forms.BaseReportForm`` interface. The interface is simple, only 3 mandatory methods to implement, The rest are mandatory only if you are working with a crosstab report or a time series report. * ``get_filters``: Mandatory, return a tuple (Q_filters , kwargs filter) to be used in filtering. q_filter: can be none or a series of Django's Q queries kwargs_filter: None or a dictionary of filters * ``get_start_date``: Mandatory, return the start date of the report. * ``get_end_date``: Mandatory, return the end date of the report. * ``get_crispy_helper`` : Optional, return a crispy form helper to be used in rendering the form. In case you are working with a crosstab report, you need to implement the following methods: * ``get_crosstab_compute_remainder``: return a boolean indicating if the remainder should be computed or not. * ``get_crosstab_ids``: return a list of ids to be used in the crosstab report. And in case you are working with a time series report, with a selector on, you need to implement the following method: * ``get_time_series_pattern``: return a string representing the time series pattern. ie: ``ie: daily, monthly, yearly`` Example a full example of a custom form: .. code-block:: python # forms.py from slick_reporting.forms import BaseReportForm # A Normal form , Inheriting from BaseReportForm class RequestLogForm(BaseReportForm, forms.Form): SECURE_CHOICES = ( ("all", "All"), ("secure", "Secure"), ("non-secure", "Not Secure"), ) start_date = forms.DateField( required=False, label="Start Date", widget=forms.DateInput(attrs={"type": "date"}), ) end_date = forms.DateField( required=False, label="End Date", widget=forms.DateInput(attrs={"type": "date"}) ) secure = forms.ChoiceField( choices=SECURE_CHOICES, required=False, label="Secure", initial="all" ) method = forms.CharField(required=False, label="Method") response = forms.ChoiceField( choices=HTTP_STATUS_CODES, required=False, label="Response", initial="200", ) other_people_only = forms.BooleanField( required=False, label="Show requests from other People Only" ) def __init__(self, *args, **kwargs): super(RequestLogForm, self).__init__(*args, **kwargs) # provide initial values and ay needed customization self.fields["start_date"].initial = datetime.date.today() self.fields["end_date"].initial = datetime.date.today() def get_filters(self): # return the filters to be used in the report # Note: the use of Q filters and kwargs filters filters = {} q_filters = [] if self.cleaned_data["secure"] == "secure": filters["is_secure"] = True elif self.cleaned_data["secure"] == "non-secure": filters["is_secure"] = False if self.cleaned_data["method"]: filters["method"] = self.cleaned_data["method"] if self.cleaned_data["response"]: filters["response"] = self.cleaned_data["response"] if self.cleaned_data["other_people_only"]: q_filters.append(~Q(user=self.request.user)) return q_filters, filters def get_start_date(self): return self.cleaned_data["start_date"] def get_end_date(self): return self.cleaned_data["end_date"] # ---- # in views.py from .forms import RequestLogForm class RequestCountByPath(ReportView): form_class = RequestLogForm You can view this code snippet in action on the demo project https://django-slick-reporting.com/total-product-sales-with-custom-form/ ================================================ FILE: docs/source/topics/group_by_report.rst ================================================ .. _group_by_topic: ================ Group By Reports ================ General use case ---------------- Group by reports are reports that group the data by a specific field, while doing some kind of calculation on the grouped fields. For example, a report that groups the expenses by the expense type. Example: .. code-block:: python class GroupByReport(ReportView): report_model = SalesTransaction report_title = _("Group By Report") date_field = "date" group_by = "product" columns = [ "name", ComputationField.create( method=Sum, field="value", name="value__sum", verbose_name="Total sold $", is_summable=True, ), ] # Charts chart_settings = [ Chart( "Total sold $", Chart.BAR, data_source=["value__sum"], title_source=["name"], ), ] A Sample group by report would look like this: .. image:: _static/group_report.png :width: 800 :alt: Group Report :align: center In the columns you can access to fields on the model that is being grouped by, in this case the Expense model, and the computation fields. Group by a traversing field --------------------------- ``group_by`` value can be a traversing field. If set, the report will be grouped by the last field in the traversing path, and, the columns available will be those of the last model in the traversing path. Example: .. code-block:: python # Inherit from previous report and make another version, keeping the columns and charts class GroupByTraversingFieldReport(GroupByReport): report_title = _("Group By Traversing Field") group_by = "product__product_category" # Note the traversing .. _group_by_custom_querysets_topic: Group by custom querysets ------------------------- Grouping can also be over a curated queryset(s). Example: .. code-block:: python 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__", ComputationField.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 ``format_row`` hook .. _no_group_by_topic: 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. ================================================ FILE: docs/source/topics/index.rst ================================================ .. _topics: Topics ====== ReportView is a ``django.views.generic.FromView`` subclass that exposes the **Report Generator API** allowing you to create a report seamlessly in a view. * Exposes the report generation options in the view class. * Auto generate the filter form based on the report model, or uses your custom form to generate and filter the report. * Return an html page prepared to display the results in a table and charts. * Export to CSV, which is extendable to apply other exporting methods. (like yaml or other) * Print the report in a dedicated page design. You saw how to use the ReportView class in the tutorial and you identified the types of reports available, in the next section we will go in depth about: #. Each type of the reports and its options. #. The general options available for all report types #. How to customize the Form #. How to customize exports and print. .. toctree:: :maxdepth: 2 :caption: Topics: :titlesonly: group_by_report time_series_options crosstab_options list_report_options filter_form widgets integrating_slick_reporting charts exporting computation_field dynamic_model pivot_report ================================================ FILE: docs/source/topics/integrating_slick_reporting.rst ================================================ Integrating reports into your front end ======================================= To integrate Slick Reporting into your application, you need to do override "slick_reporting/base.html" template, and/or, for more fine control over the report layout, override "slick_reporting/report.html" template. Example 1: Override base.html .. code-block:: html+django {% extends "base.html" %} {% block meta_page_title %} {{ report_title }}{% endblock %} {% block page_title %} {{ report_title }} {% endblock %} {% block extrajs %} {{ block.super }} {% include "slick_reporting/js_resources.html" %} {% endblock %} Let's see what we did there 1. We made our slick_reporting/base.html extend the main base.html 2. We added the ``report_title`` context variable (which hold the current report title) to the meta_page_title and page_title blocks. Use your version of these blocks, you might have them named differently. 3. We added the slick_reporting/js_resources.html template to the extrajs block. This template contains the javascript resources needed for slick_reporting to work. Also, use your version of the extrajs block. You might have it named differently. And that's it ! You can now use slick_reporting in your application. Example 2: Override report.html Maybe you want to add some extra information to the report, or change the layout of the report. You can do this by overriding the slick_reporting/report.html template. Here is how it looks like: .. code-block:: html+django {% extends 'slick_reporting/base.html' %} {% load crispy_forms_tags i18n %} {% block content %}
{% if form %}

{% trans "Filters" %}

{% crispy form crispy_helper %}
{% endif %}
{% trans "Results" %}
{% endblock %} Integrating reports into your Admin site ========================================= 1. Most probably you would want to override the default admin to add the extra report urls https://docs.djangoproject.com/en/4.2/ref/contrib/admin/#overriding-the-default-admin-site 2. Add the report url to your admin site main get_urls .. code-block:: python class CustomAdminAdminSite(admin.AdminSite): def get_urls(self): from my_apps.reports import MyAwesomeReport urls = super().get_urls() urls = [ path( "reports/my-awesome-report/", MyAwesomeReport.as_view(), name="my-awesome-report", ), ] + urls return urls Note that you need to add the reports urls to the top, or else the wildcard catch will raise a 404 3. Override slick_reporting/base.html to extend the admin site .. code-block:: html+django {% extends 'admin/base_site.html' %} {% load i18n static slick_reporting_tags %} {% block title %}{{ report_title }}{% endblock %} {% block extrahead %} {% include "slick_reporting/js_resources.html" %} {% get_charts_media "all" %} {% endblock %} {% block breadcrumbs %} {% endblock %} 4. You might want to override the report.html as well to set your styles, You can also use a different template for the crispy form ================================================ FILE: docs/source/topics/list_report_options.rst ================================================ .. _list_reports: List Reports ============ It's a simple ListView / admin changelist like report to display data in a model. It's quite similar to ReportView except there is no calculation by default. Here is the options you can use to customize the report: #. ``columns``: a list of report_model fields to be displayed in the report, which support traversing .. code-block:: python class RequestLog(ListReportView): report_model = SalesTransaction columns = [ "id", "date", "client__name", "product__name", "quantity", "price", "value", ] #. ``filters``: a list of report_model fields to be used as filters. .. code-block:: python class RequestLog(ListReportView): report_model = SalesTransaction columns = [ "id", "date", "client__name", "product__name", "quantity", "price", "value", ] filters = ["product", "client"] ================================================ FILE: docs/source/topics/pivot_report.rst ================================================ .. _pivot_report_topic: ============================ Precomputed Crosstab Reports ============================ General use case ---------------- Precomputed crosstab reports are designed for **pre-computed or pre-aggregated data** stored as rows in the database. Instead of aggregating raw transactions at report time (like regular crosstab reports do), setting ``crosstab_precomputed = True`` reads existing values and spreads a field's distinct values into columns. This is common with: * Materialized views or summary tables * Data warehouse fact tables * ETL pipeline outputs * Pre-aggregated reporting tables * Legacy tables with denormalized data Example source data: +------------+------------+-------------+----------------+ | product_id | month | total_sales | total_quantity | +============+============+=============+================+ | 1 | 2024-01-01 | 500 | 10 | +------------+------------+-------------+----------------+ | 1 | 2024-02-01 | 600 | 12 | +------------+------------+-------------+----------------+ | 2 | 2024-01-01 | 300 | 5 | +------------+------------+-------------+----------------+ | 2 | 2024-02-01 | 400 | 8 | +------------+------------+-------------+----------------+ The precomputed crosstab report transforms this into: +------------+-------------------+-------------------+ | product_id | total_sales Jan | total_sales Feb | +============+===================+===================+ | 1 | 500 | 600 | +------------+-------------------+-------------------+ | 2 | 300 | 400 | +------------+-------------------+-------------------+ How it relates to regular crosstab ---------------------------------- Precomputed crosstab is a mode of the standard crosstab feature. Both spread a field's distinct values as columns. The difference: * **Regular crosstab** (``crosstab_precomputed = False``) aggregates raw data per crosstab value using ``ComputationField`` * **Precomputed crosstab** (``crosstab_precomputed = True``) reads pre-existing values from the database — no aggregation Both modes use the same metadata and frontend integration, so all existing chart types (bar, line, pie, area) work with precomputed crosstab data. Basic example ------------- .. code-block:: python from slick_reporting.views import ReportView, Chart class MonthlyProductSales(ReportView): report_model = MonthlySummary date_field = "month" group_by = "product_id" crosstab_field = "month" crosstab_columns = ["total_sales", "total_quantity"] crosstab_precomputed = True columns = ["product_id", "__crosstab__"] chart_settings = [ Chart( "Monthly Sales", Chart.LINE, data_source=["total_sales"], title_source=["product_id"], ), ] Key attributes: * ``crosstab_precomputed = True`` — read pre-computed values instead of aggregating * ``crosstab_field`` — the column whose distinct values become the dynamic columns * ``crosstab_columns`` — list of database column name strings to read values from * ``columns`` — use ``"__crosstab__"`` as a placeholder for where crosstab columns appear Crosstab by a non-date field ----------------------------- The crosstab field doesn't have to be a date — it can be any column: .. code-block:: python class SalesByRegion(ReportView): report_model = RegionalSummary group_by = "product_id" crosstab_field = "region" crosstab_columns = ["total_sales"] crosstab_precomputed = True columns = ["product_id", "__crosstab__"] This produces one row per product with a column for each region. .. note:: When multiple source rows exist for the same ``(group_by, crosstab_field)`` combination, the last row encountered is used. Precomputed crosstab does **not** aggregate — if you need aggregation, use a standard crosstab (without ``crosstab_precomputed``) or time series report instead. Using with dynamic models -------------------------- Precomputed crosstab reports pair naturally with ``table_name`` for tables without a Django model: .. code-block:: python class WarehouseReport(ReportView): table_name = "warehouse_monthly_sales" date_field = "period_date" group_by = "sku" crosstab_field = "period_date" crosstab_columns = ["revenue", "units_sold"] crosstab_precomputed = True columns = ["sku", "__crosstab__"] See :ref:`dynamic_model_topic` for more on reporting from arbitrary database tables. Using with ReportGenerator directly ------------------------------------ .. code-block:: python from slick_reporting.generator import ReportGenerator report = ReportGenerator( report_model=MonthlySummary, group_by="product_id", date_field="month", crosstab_field="month", crosstab_columns=["total_sales"], crosstab_precomputed=True, columns=["product_id", "__crosstab__"], start_date=datetime.datetime(2024, 1, 1), end_date=datetime.datetime(2024, 6, 30), ) data = report.get_report_data() Date filtering -------------- When ``date_field`` is set, the precomputed crosstab respects ``start_date`` and ``end_date`` filters. Only rows within the date range are included, and only crosstab values found in those rows appear as columns. Crosstab values with spaces or special characters -------------------------------------------------- Crosstab values like ``"New York"`` or ``"Q1/2024"`` are sanitized for use in column names (non-alphanumeric characters replaced with underscores). The ``verbose_name`` preserves the original value for display in tables and charts. For example, a crosstab value of ``"New York"`` produces: * Column name: ``total_salesCTNew_York`` (sanitized, used internally) * Verbose name: ``total_sales New York`` (original, displayed to users) Limitations ----------- * **No aggregation.** Precomputed crosstab reads pre-existing values. If you need computation (Sum, Count, etc.), use a standard crosstab without ``crosstab_precomputed``. * **Last value wins.** If multiple rows share the same ``(group_by, crosstab_field)`` value, the last row's values are used. * **No FK traversal.** When using dynamic models, foreign key columns are plain integers. Group by the column directly (e.g. ``product_id``) rather than traversing (``product__name``). ================================================ FILE: docs/source/topics/structure.rst ================================================ .. _structure: ================ Rows and columns ================ It's natural to think of a report as a form of tabular data, with rows and columns. We willexplore the ways one can create the rows and column of a report. a simple example ================================================ FILE: docs/source/topics/time_series_options.rst ================================================ .. _time_series: Time Series Reports =================== A Time series report is a report that is generated for a periods of time. The period can be daily, weekly, monthly, yearly or custom, calculations will be performed for each period in the time series. General use case ---------------- Here is a quick look at the general use case .. code-block:: python from django.utils.translation import gettext_lazy as _ from django.db.models import Sum from slick_reporting.views import ReportView 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" # These columns will be calculated for each period in the time series. time_series_columns = [ ComputationField.create(Sum, "value", verbose_name=_("Sales For Month")), ] columns = [ "name", "__time_series__", # This is the same as the time_series_columns, but this one will be on the whole set ComputationField.create(Sum, "value", verbose_name=_("Total Sales")), ] chart_settings = [ Chart( "Client Sales", Chart.BAR, data_source=["sum__value"], title_source=["name"], ), Chart( "Total Sales Monthly", Chart.PIE, data_source=["sum__value"], title_source=["name"], plot_total=True, ), Chart( "Total Sales [Area chart]", Chart.AREA, data_source=["sum__value"], title_source=["name"], ), ] Allowing the User to Choose the time series pattern --------------------------------------------------- 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")), ) 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. 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 ``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: .. code-block:: python class SumOfFieldValue(ComputationField): # A custom computation Field identical to the one created like this # Similar to `ComputationField.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 ComputationField 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 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: Time Series Options ------------------- .. attribute:: ReportView.time_series_pattern the time series pattern to be used in the report, it can be one of the following: Possible options are: daily, weekly, semimonthly, monthly, quarterly, semiannually, annually and custom. if `custom` is set, you'd need to override `time_series_custom_dates` .. attribute:: ReportView.time_series_custom_dates A list of tuples of (start_date, end_date) pairs indicating the start and end of each period. .. attribute:: ReportView.time_series_columns a list of Calculation Field names which will be included in the series calculation. .. code-block:: python class MyReport(ReportView): time_series_columns = [ ComputationField.create( Sum, "value", verbose_name=_("Value"), is_summable=True, name="sum__value" ), ComputationField.create( Avg, "Price", verbose_name=_("Avg Price"), is_summable=False ), ] Links to demo '''''''''''''' Time series Selector pattern `Demo `_ and the `Code on github `_ for it. ================================================ FILE: docs/source/topics/widgets.rst ================================================ .. _widgets: .. _dashboard: Dashboards ========== You can use the report data and charts on any other page, for example to create a dashboard. A dashboard page is a collection of report results / charts / tables. Adding a widget to a page is as easy as this code .. code-block:: html+django {% load static slick_reporting_tags %} {% block content %}
{% get_widget_from_url url_name="product-sales" %}
{% endblock %} {% block extrajs %} {% include "slick_reporting/js_resources.html" %} {% get_charts_media "all" %} {% endblock %} The `get_widget_from_url` with create a card block, which will contain the report results and charts. You can customize the widget by passing arguments to the template tag. Arguments --------- * 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` * extra_params: string, extra parameters to pass to the report. * report_form_selector: string, a jquery selector that will be used to find the form that will be used to pass extra parameters to the report. 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. The ``data-report-chart`` attribute is used by the javascript to find the chart container and render the chart and the chart selector. The ``data-report-table`` attribute is used by the javascript to find the table container and render the table. Customization Example --------------------- 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 slick_reporting_tags %} {% get_widget_from_url url_name="product-sales" success_callback=my_success_callback %} Live example: ------------- You can see a live example of the widgets in the `Demo project- Dashboard Page `_. ================================================ FILE: docs/source/tour.rst ================================================ .. _usage: A walk through ============== Update ~~~~~~ You can now go to https://django-slick-reporting for a better and practical guidance on the types of reports and what you can do. Given that you have a model where there are data stored which you want to generate reports on. Consider below SalesOrder model example. +------------+------------+-----------+----------+-------+-------+ | order_date | product_id | client_id | quantity | price | value | +------------+------------+-----------+----------+-------+-------+ | 2019-01-01 | 1 | 1 | 5 | 15 | 75 | +------------+------------+-----------+----------+-------+-------+ | 2019-02-15 | 2 | 2 | 7 | 20 | 140 | +------------+------------+-----------+----------+-------+-------+ | 2019-02-20 | 2 | 1 | 5 | 20 | 100 | +------------+------------+-----------+----------+-------+-------+ | 2019-03-14 | 1 | 2 | 3 | 15 | 45 | +------------+------------+-----------+----------+-------+-------+ Slick Reporting help us answer some questions, like: * To start: Wouldn't it be nice if we have a view page where we can filter the data based on date , client(s) and or product(s) * How much each product was sold or How much each Client bought? Filter by date range / client(s) / product(s) * How well each product sales is doing, monthly? * How client 1 compared with client 2, compared with the rest of clients, on each product sales ? * How many orders were created a day ? To answer those question, We can identify basic kind of alteration / calculation on the data 1. Basic filtering ------------------ Start small, A ReportView like the below .. code-block:: python # in your urls.py path("path-to-report", TransactionsReport.as_view()) # in your views.py from slick_reporting.views import ReportView class TransactionsReport(ReportView): report_model = MySalesItem columns = [ "order_date", "product__name", "client__name", "quantity", "price", "value", ] will yield a Page with a nice filter form with A report where it displays the data as is but with filters however we can apply date and other filters +------------+---------------+-------------+----------+-------+-------+ | order_date | Product Name | Client Name | quantity | price | value | +------------+---------------+-------------+----------+-------+-------+ | 2019-01-01 | Product 1 | Client 1 | 5 | 15 | 75 | +------------+---------------+-------------+----------+-------+-------+ | 2019-02-15 | Product 2 | Client 2 | 7 | 20 | 140 | +------------+---------------+-------------+----------+-------+-------+ | 2019-02-20 | Product 2 | Client 1 | 5 | 20 | 100 | +------------+---------------+-------------+----------+-------+-------+ | 2019-03-14 | Product 1 | Client 2 | 3 | 15 | 45 | +------------+---------------+-------------+----------+-------+-------+ 2. Group By report ------------------- Where we can group by product -for example- and sum the quantity, or value sold. +-----------+----------------+-------------+ | Product | Total Quantity | Total Value | +-----------+----------------+-------------+ | Product 1 | 8 | 120 | +-----------+----------------+-------------+ | Product 2 | 13 | 240 | +-----------+----------------+-------------+ which can be written like this: .. code-block:: python class TotalQuanAndValueReport(ReportView): report_model = MySalesItem group_by = "product" columns = ["name", "__total_quantity__", "__total__"] 3. Time Series report ------------------------ where we can say how much sum of the quantity sold over a chunks of time periods (like weekly, monthly, ... ) +--------------+----------------------+-----------------+----------------+-----------------------+-------------------------------+ | Product Name | SKU | Total Quantity | Total Quantity | Total Quantity in ... | Total Quantity in December 20 | | | | in Jan 20 | in Feb 20 | | | +--------------+----------------------+-----------------+----------------+-----------------------+-------------------------------+ | Product 1 | | 5 | 0 | ... | 14 | +--------------+----------------------+-----------------+----------------+-----------------------+-------------------------------+ | Product 2 | | 0 | 13 | ... | 12 | +--------------+----------------------+-----------------+----------------+-----------------------+-------------------------------+ | Product 3 | | 17 | 12 | ... | 17 | +--------------+----------------------+-----------------+----------------+-----------------------+-------------------------------+ can be written like this .. code-block:: python class TotalQuantityMonthly(ReportView): report_model = MySalesItem group_by = "product" columns = ["name", "sku"] time_series_pattern = "monthly" time_series_columns = ["__total_quantity__"] 4. Cross tab report -------------------- Where we can cross product sold over client for example +--------------+----------------------+-----------------+----------------+-----------------------+-------------------------------+ | Product Name | SKU | Client 1 | Client 2 | Client (n) | The Reminder | | | | Total value | Total Value | | | +--------------+----------------------+-----------------+----------------+-----------------------+-------------------------------+ | Product 1 | | 10 | 15 | ... | 14 | +--------------+----------------------+-----------------+----------------+-----------------------+-------------------------------+ | Product 2 | | 11 | 12 | ... | 12 | +--------------+----------------------+-----------------+----------------+-----------------------+-------------------------------+ | Product 3 | | 17 | 12 | ... | 17 | +--------------+----------------------+-----------------+----------------+-----------------------+-------------------------------+ Which can be written like this .. code-block:: python class CrosstabProductClientValue(ReportView): report_model = MySalesItem group_by = "product" columns = ["name", "sku"] crosstab_model = "client" crosstab_columns = ["__total_value__"] crosstab_ids = [client1.pk, client2.pk, client3.pk] crosstab_compute_remainder = True 5. Time series - Cross tab -------------------------- (#2 & #3 together) Not support at the time.. but soon we hope. ================================================ FILE: docs/source/tutorial.rst ================================================ .. _tutorial: ========= Tutorial ========= In this tutorial we will go over how to create different reports using Slick Reporting and integrating them into your projects. Let' say you have a Sales Transaction model in your project. Schema looking like this: .. code-block:: python from django.db import models from django.utils.translation import gettext_lazy as _ class Client(models.Model): name = models.CharField(_("Name"), max_length=255) country = models.CharField(_("Country"), max_length=255, default="US") class Product(models.Model): name = models.CharField(_("Name"), max_length=255) sku = models.CharField(_("SKU"), max_length=255) class Sales(models.Model): doc_date = models.DateTimeField(_("date"), db_index=True) client = models.ForeignKey(Client, on_delete=models.CASCADE) product = models.ForeignKey(Product, on_delete=models.CASCADE) quantity = models.DecimalField( _("Quantity"), max_digits=19, decimal_places=2, default=0 ) price = models.DecimalField(_("Price"), max_digits=19, decimal_places=2, default=0) value = models.DecimalField(_("Value"), max_digits=19, decimal_places=2, default=0) Now, you want to extract the following information from that sales model, present to your users in a nice table and chart: #. Total sales per product. #. Total Sales per client country. #. Total sales per product each month. #. Total Sales per product and country. #. Total Sales per product and country, per month. #. Display last 10 sales transactions. Group By Reports ================ 1. Total sales per product -------------------------- This can be done via an SQL statement looking like this: .. code-block:: sql SELECT product_id, SUM(value) FROM sales GROUP BY product_id; In Slick Reporting, you can do the same thing by creating a report view looking like this: .. code-block:: python # in views.py from django.db.models import Sum from slick_reporting.views import ReportView, Chart from slick_reporting.fields import ComputationField from .models import Sales class TotalProductSales(ReportView): report_model = SalesTransaction date_field = "date" group_by = "product" columns = [ "name", ComputationField.create( Sum, "quantity", verbose_name="Total quantity sold", is_summable=False ), ComputationField.create( Sum, "value", name="sum__value", verbose_name="Total Value sold $" ), ] 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"], ), ] Then in your urls.py add the following: .. code-block:: python from django.urls import path from .views import TotalProductSales urlpatterns = [ path( "total-product-sales/", TotalProductSales.as_view(), name="total-product-sales" ), ] Now visit the url ``/total-product-sales/`` and you will see the page report. Containing a Filter Form, the report table and a chart. You can change the dates in the filter form , add some filters and the report will be updated. You can also export the report to CSV. 2. Total Sales per each client country -------------------------------------- .. code-block:: python # in views.py from django.db.models import Sum from slick_reporting.views import ReportView, Chart from slick_reporting.fields import ComputationField from .models import SalesTransaction class TotalProductSalesByCountry(ReportView): report_model = SalesTransaction date_field = "date" group_by = "client__country" # notice the double underscore columns = [ "client__country", ComputationField.create( Sum, "value", name="sum__value", verbose_name="Total Value sold by country $", ), ] chart_settings = [ Chart( "Total sold by country $", Chart.PIE, # A Pie Chart data_source=["sum__value"], title_source=["client__country"], ), ] Time Series Reports ==================== A time series report is a report that computes the data for each period of time. For example, if you want to see the total sales per each month, then you need to create a time series report. .. code-block:: python from django.utils.translation import gettext_lazy as _ from slick_reporting.fields import ComputationField class SumValueComputationField(ComputationField): calculation_method = Sum calculation_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"], ), ] then again in your urls.py add the following: .. code-block:: python from django.urls import path from .views import MonthlyProductSales urlpatterns = [ path( "monthly-product-sales/", MonthlyProductSales.as_view(), name="monthly-product-sales", ), ] Note: We created SumValueComputationField to avoid repeating the same code in each report. You can create your own ``ComputationFields`` and use them in your reports. Pretty Cool yes ? CrossTab Reports ================ A crosstab report shows the relation between two or more variables. For example, if you want to see the total sales per each product and country, then you need to create a crosstab report. .. code-block:: python 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, ] Then again in your urls.py add the following: .. code-block:: python from django.urls import path from .views import MyCrosstabReport urlpatterns = [ path( "product-sales-per-country/", ProductSalesPerCountryCrosstab.as_view(), name="product-sales-per-country", ), ] List Reports ============ A list report is a report that shows a list of records. For example, if you want to see the last 10 sales transactions, then you need to create a list report. .. code-block:: python from slick_reporting.views import ListReportView class LastTenSales(ListReportView): report_model = SalesTransaction report_title = "Last 10 sales" date_field = "date" filters = ["product", "client", "date"] columns = [ "product__name", "client__name", "date", "quantity", "price", "value", ] default_order_by = "-date" limit_records = 10 Then again in your urls.py add the following: .. code-block:: python from django.urls import path from .views import LastTenSales urlpatterns = [ path( "last-ten-sales/", LastTenSales.as_view(), name="last-ten-sales", ), ] Integrate the view in your project =================================== You can use the template in your own project by following these steps: #. Override ``slick_reporting/base.html`` in your own project and make it extends you own base template. #. Make sure your base template has a ``{% block content %}`` block and a ``{% block extrajs %}`` block. #. Add the slick reporting js resources to the page by adding `{% include "slick_reporting/js_resources.html" %}` to an appropriate block. Overriding the Form =================== The system expect that the form used with the ``ReportView`` to implement the ``slick_reporting.forms.BaseReportForm`` interface. The interface is simple, only 3 mandatory methods to implement, The rest are mandatory only if you are working with a crosstab report or a time series report. * ``get_filters``: Mandatory, return a tuple (Q_filters , kwargs filter) to be used in filtering. q_filter: can be none or a series of Django's Q queries kwargs_filter: None or a dictionary of filters * ``get_start_date``: Mandatory, return the start date of the report. * ``get_end_date``: Mandatory, return the end date of the report. For detailed information about the form, please check :ref:`filter_form` Example ------- .. code-block:: python # forms.py from django import forms from django.db.models import Q from slick_reporting.forms import BaseReportForm # A Normal form , Inheriting from 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"] Recap ===== In the tutorial we went over how to create a report using the ``ReportView`` and ``ListReportView`` classes. The different types of reports we created are: 1. Grouped By Reports 2. Time Series Reports 3. Crosstab Reports 4. List Reports You can create a report by inheriting from ``ReportView`` or ``ListReportView`` and setting the following attributes: * ``report_model``: The model to be used in the report * ``date_field``: The date field to be used in the report * ``columns``: The columns to be displayed in the report * ``default_order_by``: The default order by for the report * ``limit_records``: The limit of records to be displayed in the report * ``group_by``: The field to be used to group the report by * ``time_series_pattern``: The time series pattern to be used in the report * ``time_series_columns``: The columns to be displayed in the time series report * ``crosstab_field``: The field to be used to create a crosstab report * ``crosstab_columns``: The columns to be displayed in the crosstab report * ``crosstab_ids``: The ids to be used in the crosstab report * ``crosstab_compute_remainder``: Whether to compute the remainder in the crosstab report * ``chart_settings``: The chart settings to be used in the report We also saw how you can customize the form used in the report by inheriting from ``BaseReportForm``, and integrating the view in your project. ================================================ FILE: pyproject.toml ================================================ [build-system] requires = ["setuptools>=61.0"] build-backend = "setuptools.build_meta" [tool.black] line-length = 120 [tool.ruff] line-length = 120 ================================================ FILE: requirements.txt ================================================ Django python-dateutil>=2.8.1 pytz simplejson django-crispy-forms ================================================ FILE: runtests.py ================================================ #!/usr/bin/env python import os import sys import argparse import django from django.conf import settings from django.test.utils import get_runner if __name__ == "__main__": parser = argparse.ArgumentParser( description="Run the Django Slick Reporting test suite." ) parser.add_argument( "modules", nargs="*", metavar="module", help='Optional path(s) to test modules; e.g. "i18n" or ' '"i18n.tests.TranslationTests.test_lazy_objects".', ) options = parser.parse_args() options.modules = [os.path.normpath(labels) for labels in options.modules] os.environ["DJANGO_SETTINGS_MODULE"] = "tests.settings" django.setup() TestRunner = get_runner(settings) test_runner = TestRunner() failures = test_runner.run_tests(options.modules) # failures = test_runner.run_tests(["tests"]) sys.exit(bool(failures)) ================================================ FILE: scripts/extract_changelog.py ================================================ #!/usr/bin/env python """Extract a single version's section from CHANGELOG.md and print it to stdout. Usage: python scripts/extract_changelog.py 1.4.0 """ import re import sys def extract(version: str, changelog_path: str = "CHANGELOG.md") -> str: with open(changelog_path) as f: text = f.read() # Match the heading for the requested version (e.g. ## [1.4.0] or ## [1.4.0] - 2026-05-01) pattern = rf"(^## \[{re.escape(version)}\][^\n]*\n)(.*?)(?=^## \[|\Z)" match = re.search(pattern, text, re.MULTILINE | re.DOTALL) if not match: print(f"Version {version} not found in {changelog_path}", file=sys.stderr) sys.exit(1) # Return the body without the heading line itself return match.group(2).strip() if __name__ == "__main__": if len(sys.argv) != 2: print(f"Usage: {sys.argv[0]} ", file=sys.stderr) sys.exit(1) print(extract(sys.argv[1])) ================================================ FILE: setup.cfg ================================================ [metadata] license_file = LICENSE.md name = django-slick-reporting version = attr: slick_reporting.__version__ author = Ra Systems author_email = ramez@rasystems.io description = A one-stop report and analytics generation and computation with batteries included long_description = file:README.rst long_description_content_type = text/x-rst url = https://django-slick-reporting.com/ project_urls = Documentation = https://django-slick-reporting.readthedocs.io/en/latest/ Source = https://github.com/ra-systems/django-slick-reporting Changelog = https://github.com/ra-systems/django-slick-reporting/blob/master/CHANGELOG.md classifiers = Environment :: Web Environment Framework :: Django Framework :: Django :: 4.2 Framework :: Django :: 5.0 Framework :: Django :: 5.1 Intended Audience :: Developers Development Status :: 5 - Production/Stable License :: OSI Approved :: BSD License Natural Language :: English Operating System :: MacOS :: MacOS X Operating System :: POSIX Operating System :: POSIX :: BSD Operating System :: POSIX :: Linux Operating System :: Microsoft :: Windows Programming Language :: Python Programming Language :: Python :: 3 Programming Language :: Python :: 3 :: Only Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 Programming Language :: Python :: 3.11 Programming Language :: Python :: 3.12 Topic :: Internet :: WWW/HTTP Topic :: Internet :: WWW/HTTP :: Dynamic Content [options] include_package_data = true packages = find: python_requires = >=3.9 install_requires = django>=2.2 python-dateutil>2.8.1 pytz simplejson django-crispy-forms ================================================ FILE: setup.py ================================================ from setuptools import setup setup() ================================================ FILE: slick_reporting/__init__.py ================================================ default_app_config = "slick_reporting.apps.ReportAppConfig" VERSION = (1, 4, 0) __version__ = "1.4.0" ================================================ FILE: slick_reporting/app_settings.py ================================================ from django.conf import settings from django.urls import get_callable from django.utils.functional import lazy from django.utils.translation import gettext_lazy as _ import datetime def get_first_of_this_year(): d = datetime.datetime.today() return datetime.datetime(d.year, 1, 1, 0, 0) def get_end_of_this_year(): d = datetime.datetime.today() return datetime.datetime(d.year + 1, 1, 1, 0, 0) def get_start_date(): start_date = getattr(settings, "SLICK_REPORTING_DEFAULT_START_DATE", False) return start_date or get_first_of_this_year() def get_end_date(): end_date = getattr(settings, "SLICK_REPORTING_DEFAULT_END_DATE", False) return end_date or datetime.datetime.today() SLICK_REPORTING_DEFAULT_START_DATE = lazy(get_start_date, datetime.datetime)() SLICK_REPORTING_DEFAULT_END_DATE = lazy(get_end_date, datetime.datetime)() SLICK_REPORTING_DEFAULT_CHARTS_ENGINE = getattr(settings, "SLICK_REPORTING_DEFAULT_CHARTS_ENGINE", "highcharts") SLICK_REPORTING_JQUERY_URL = getattr( settings, "SLICK_REPORTING_JQUERY_URL", "https://code.jquery.com/jquery-3.7.0.min.js" ) SLICK_REPORTING_SETTINGS_DEFAULT = { "JQUERY_URL": SLICK_REPORTING_JQUERY_URL, "DEFAULT_START_DATE_TIME": get_start_date(), "DEFAULT_END_DATE_TIME": get_end_date(), "DEFAULT_CHARTS_ENGINE": SLICK_REPORTING_DEFAULT_CHARTS_ENGINE, "MEDIA": { "override": False, "js": ( "https://cdn.jsdelivr.net/momentjs/latest/moment.min.js", "https://cdn.datatables.net/1.13.4/js/jquery.dataTables.min.js", "https://cdn.datatables.net/1.13.4/js/dataTables.bootstrap5.min.js", "slick_reporting/slick_reporting.js", "slick_reporting/slick_reporting.report_loader.js", "slick_reporting/slick_reporting.datatable.js", ), "css": { "all": ( "https://cdn.datatables.net/1.13.4/css/dataTables.bootstrap5.min.css", ) }, }, "FONT_AWESOME": { "CSS_URL": "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.3/css/all.min.css", "ICONS": { "pie": "fas fa-chart-pie", "bar": "fas fa-chart-bar", "line": "fas fa-chart-line", "area": "fas fa-chart-area", "column": "fas fa-chart-bar", }, }, "CHARTS": { "highcharts": { "entryPoint": "$.slick_reporting.highcharts.displayChart", "js": ( "https://cdn.jsdelivr.net/npm/highcharts@11/highcharts.js", "slick_reporting/slick_reporting.highchart.js", ), }, "chartsjs": { "entryPoint": "$.slick_reporting.chartsjs.displayChart", "js": ("https://cdn.jsdelivr.net/npm/chart.js", "slick_reporting/slick_reporting.chartsjs.js"), }, }, "MESSAGES": { "total": _("Total"), "export_to_csv": _("Export to CSV"), "print_report": _("Print"), }, "REPORT_VIEW_ACCESS_FUNCTION": "slick_reporting.helpers.user_test_function", } def get_slick_reporting_settings(): slick_settings = SLICK_REPORTING_SETTINGS_DEFAULT.copy() slick_chart_settings = slick_settings["CHARTS"].copy() user_settings = getattr(settings, "SLICK_REPORTING_SETTINGS", {}) user_chart_settings = user_settings.get("CHARTS", {}) user_media_settings = user_settings.get("MEDIA", {}) override_media = user_media_settings.get("override", False) if override_media: slick_settings["MEDIA"] = user_media_settings else: slick_settings["MEDIA"]["js"] = slick_settings["MEDIA"]["js"] + user_media_settings.get("js", ()) slick_settings["MEDIA"]["css"]["all"] = slick_settings["MEDIA"]["css"]["all"] + user_media_settings.get( "css", {} ).get("all", ()) slick_chart_settings.update(user_chart_settings) slick_settings.update(user_settings) slick_settings["CHARTS"] = slick_chart_settings # slick_settings = {**SLICK_REPORTING_SETTINGS_DEFAULT, **getattr(settings, "SLICK_REPORTING_SETTINGS", {})} start_date = getattr(settings, "SLICK_REPORTING_DEFAULT_START_DATE", False) end_date = getattr(settings, "SLICK_REPORTING_DEFAULT_END_DATE", False) # backward compatibility, todo remove in next major release if start_date: slick_settings["DEFAULT_START_DATE_TIME"] = start_date if end_date: slick_settings["DEFAULT_END_DATE_TIME"] = end_date return slick_settings SLICK_REPORTING_SETTINGS = lazy(get_slick_reporting_settings, dict)() def get_media(): return SLICK_REPORTING_SETTINGS["MEDIA"] def get_access_function(): return get_callable(SLICK_REPORTING_SETTINGS["REPORT_VIEW_ACCESS_FUNCTION"]) ================================================ FILE: slick_reporting/apps.py ================================================ from django import apps class ReportAppConfig(apps.AppConfig): verbose_name = "Slick Reporting" name = "slick_reporting" def ready(self): super().ready() from . import fields # noqa ================================================ FILE: slick_reporting/decorators.py ================================================ def report_field_register(report_field, *args, **kwargs): """ Registers the given model(s) classes and wrapped ModelAdmin class with admin site: @register(Author) class AuthorAdmin(admin.ModelAdmin): pass A kwarg of `site` can be passed as the admin site, otherwise the default admin site will be used. """ from .fields import ComputationField from .registry import field_registry def _model_admin_wrapper(admin_class): if not issubclass(admin_class, ComputationField): raise ValueError("Wrapped class must subclass ComputationField.") field_registry.register(report_field) _model_admin_wrapper(report_field) return report_field ================================================ FILE: slick_reporting/dynamic_model.py ================================================ from django.db import models, connections, OperationalError, ProgrammingError _model_cache = {} FIELD_TYPE_MAP = { "AutoField": models.AutoField, "BigAutoField": models.BigAutoField, "SmallAutoField": models.SmallAutoField, "BigIntegerField": models.BigIntegerField, "BooleanField": models.BooleanField, "CharField": models.CharField, "DateField": models.DateField, "DateTimeField": models.DateTimeField, "DecimalField": models.DecimalField, "DurationField": models.DurationField, "FloatField": models.FloatField, "IntegerField": models.IntegerField, "PositiveIntegerField": models.PositiveIntegerField, "PositiveBigIntegerField": models.PositiveBigIntegerField, "PositiveSmallIntegerField": models.PositiveSmallIntegerField, "SmallIntegerField": models.SmallIntegerField, "TextField": models.TextField, "TimeField": models.TimeField, "BinaryField": models.BinaryField, "UUIDField": models.UUIDField, "JSONField": models.JSONField, "GenericIPAddressField": models.GenericIPAddressField, "IPAddressField": models.GenericIPAddressField, "SlugField": models.SlugField, "URLField": models.URLField, "FilePathField": models.FilePathField, } def _make_field(field_type_str, column_info, is_pk): """Create a Django field instance from introspection data.""" field_class = FIELD_TYPE_MAP.get(field_type_str, models.TextField) kwargs = {} if is_pk: if field_class in (models.AutoField, models.BigAutoField, models.SmallAutoField): return field_class(primary_key=True) kwargs["primary_key"] = True else: if column_info.null_ok: kwargs["null"] = True kwargs["blank"] = True if field_class == models.CharField: max_length = column_info.display_size if not max_length or max_length < 0 or max_length > 10000: max_length = 255 kwargs["max_length"] = max_length elif field_class == models.DecimalField: kwargs["max_digits"] = column_info.precision if column_info.precision else 19 kwargs["decimal_places"] = column_info.scale if column_info.scale else 2 elif field_class in (models.SlugField, models.URLField): max_length = column_info.display_size if not max_length or max_length < 0 or max_length > 10000: max_length = 255 if field_class == models.SlugField else 200 kwargs["max_length"] = max_length return field_class(**kwargs) def get_dynamic_model(table_name, database="default", schema=None): """ Introspect a database table and return a Django model class for it. The returned model is a real Django model with ``managed = False``, so all ORM operations (filter, annotate, aggregate, etc.) work natively. Results are cached so repeated calls for the same table return the same class. Args: table_name: The database table name to introspect. database: The database alias to use (default: 'default'). schema: Optional database schema name (e.g. 'analytics'). On PostgreSQL, the schema must be in the connection's ``search_path`` for introspection to find the table. If provided, the model's ``db_table`` will be set to ``"schema"."table"`` so ORM queries reference the correct schema. Returns: A Django model class mapped to the given table. Raises: ValueError: If the table does not exist in the database. """ db_table = f'"{schema}"."{table_name}"' if schema else table_name cache_key = f"{database}:{db_table}" if cache_key in _model_cache: return _model_cache[cache_key] connection = connections[database] try: with connection.cursor() as cursor: tables = connection.introspection.table_names(cursor) if table_name not in tables: raise ValueError( f"Table '{table_name}' does not exist in the '{database}' database. " f"Available tables: {', '.join(sorted(tables))}" ) table_description = connection.introspection.get_table_description(cursor, table_name) try: pk_columns = connection.introspection.get_primary_key_columns(cursor, table_name) except AttributeError: # Fallback for older Django versions pk_column = connection.introspection.get_primary_key_column(cursor, table_name) pk_columns = [pk_column] if pk_column else [] except (OperationalError, ProgrammingError) as exc: raise RuntimeError( f"slick_reporting: Could not introspect table '{table_name}' — " f"the database is not ready (migrations may not have run yet). " f"Original error: {exc}" ) from exc fields = {} has_pk = False for col_info in table_description: result = connection.introspection.get_field_type(col_info.type_code, col_info) # get_field_type returns a string in modern Django, or (string, params) in older versions if isinstance(result, tuple): field_type_str = result[0] else: field_type_str = result is_pk = col_info.name in pk_columns if is_pk: has_pk = True field = _make_field(field_type_str, col_info, is_pk) field.db_column = col_info.name fields[col_info.name] = field if not has_pk: # Table has no PK — add a synthetic one on the first column if table_description: first_col = table_description[0].name result = connection.introspection.get_field_type( table_description[0].type_code, table_description[0] ) ft = result[0] if isinstance(result, tuple) else result fields[first_col] = _make_field(ft, table_description[0], is_pk=True) fields[first_col].db_column = first_col # Build a valid Python class name from the table name name_parts = table_name.replace(".", "_").split("_") if schema: name_parts = schema.replace(".", "_").split("_") + name_parts model_name = "".join(part.capitalize() for part in name_parts) if not model_name: model_name = "DynamicModel" attrs = { "__module__": __name__, "Meta": type( "Meta", (), { "managed": False, "db_table": db_table, "app_label": "slick_reporting", }, ), } attrs.update(fields) model = type(model_name, (models.Model,), attrs) # Register in Django's app registry from django.apps import apps app_models = apps.all_models.get("slick_reporting", {}) model_key = model_name.lower() if model_key in app_models: # Already registered — reuse the existing model model = app_models[model_key] else: apps.register_model("slick_reporting", model) _model_cache[cache_key] = model return model ================================================ FILE: slick_reporting/fields.py ================================================ from __future__ import annotations from warnings import warn from django.db.models import Sum, Q from django.template.defaultfilters import date as date_filter from django.utils.translation import gettext_lazy as _ from .registry import field_registry class ComputationField(object): """ Computation field responsible for making the calculation unit """ _field_registry = field_registry name = "" """The name to be used in the ReportGenerator""" calculation_field = "value" """the Field to compute on""" calculation_method = Sum """The computation Method""" verbose_name = None """Verbose name to be used in front end when needed""" requires = None """This can be a list of sibling classes, they will be asked to compute and their value would be available to you in the `resolve` method requires = [BasicCalculationA, BasicCalculationB] """ type = "number" """Just a string describing what this computation field return, usually passed to frontend""" is_summable = True """Indicate if this computation can be summed over. Useful to be passed to frontend or whenever needed""" report_model = None """The model on which the computation would occur""" queryset = None """The queryset on which the computation would occur""" group_by = None group_by_custom_querysets = None plus_side_q = None minus_side_q = None base_kwargs_filters = None base_q_filters = None _require_classes = None _debit_and_credit = True prevent_group_by = False """Will prevent group by calculation for this specific field, serves when you want to compute overall results""" def __new__(cls, *args, **kwargs): """ This is where we register the class in the registry :param args: :param kwargs: :return: """ if not cls.name: raise ValueError(f"ReportField {cls} must have a name") return super(ComputationField, cls).__new__(cls) @classmethod def create(cls, method, field, name=None, verbose_name=None, is_summable=True): """ Creates a ReportField class on the fly :param method: The computation Method to be used :param field: The field on which the computation would occur :param name: a name to refer to this field else where :param verbose_name: Verbose name :param is_summable: :return: """ if not name: name = name or f"{method.name.lower()}__{field}" assert name not in cls._field_registry.get_all_report_fields_names() verbose_name = verbose_name or f"{method.name} {field}" report_klass = type( f"ReportField_{name}", (cls,), { "name": name, "verbose_name": verbose_name, "calculation_field": field, "calculation_method": method, "is_summable": is_summable, }, ) return report_klass def __init__( self, plus_side_q=None, minus_side_q=None, report_model=None, queryset=None, calculation_field=None, calculation_method=None, date_field="", group_by=None, group_by_custom_querysets=None, ): 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.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.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 [] self.group_by = self.group_by or group_by self._cache = None, None, None self._require_classes = self._get_required_classes() self._required_prepared_results = None self._debit_and_credit = self.plus_side_q or self.minus_side_q @classmethod def _get_required_classes(cls): requires = cls.requires or [] return [field_registry.get_field_by_name(x) if isinstance(x, str) else x for x in requires] def apply_aggregation(self, queryset, group_by=""): annotation = self.calculation_method(self.calculation_field) if self.group_by_custom_querysets: return queryset.aggregate(annotation) elif group_by: queryset = queryset.values(group_by).annotate(annotation) queryset = {str(x[self.group_by]): x for x in queryset} else: queryset = queryset.aggregate(annotation) return queryset def init_preparation(self, q_filters=None, kwargs_filters=None, **kwargs): """ Called by the generator to prepare the calculation of this field + it's requirements :param q_filters: :param kwargs_filters: :param kwargs: :return: """ kwargs_filters = kwargs_filters or {} required_prepared_results = self._prepare_required_computations(q_filters, kwargs_filters.copy()) queryset = self.get_queryset() if self.group_by_custom_querysets: debit_results, credit_results = self.prepare_custom_group_by_queryset(q_filters, kwargs_filters, **kwargs) else: debit_results, credit_results = self.prepare( q_filters, kwargs_filters, queryset, self.group_by, self.prevent_group_by, **kwargs, ) self._cache = debit_results, credit_results self._required_prepared_results = required_prepared_results def prepare_custom_group_by_queryset(self, q_filters=None, kwargs_filters=None, **kwargs): debit_output, credit_output = [], [] 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: list | object = None, kwargs_filters: dict = None, main_queryset=None, group_by: str = None, prevent_group_by=None, **kwargs, ): """ This is the first hook where you can customize the calculation away from the Django Query aggregation method This method is called with all available arguments, so you can prepare the results for the whole set and save it in a local cache (like self._cache) . The flow will later call the method `resolve`, giving you the id, for you to return it respective calculation :param q_filters: :param kwargs_filters: :param main_queryset: :param group_by: :param prevent_group_by: :param kwargs: :return: """ queryset = main_queryset.all() group_by = "" if prevent_group_by else group_by credit_results = None if q_filters: if type(q_filters) is Q: q_filters = [q_filters] queryset = queryset.filter(*q_filters) if kwargs_filters: queryset = queryset.filter(**kwargs_filters) if self.plus_side_q: queryset = queryset.filter(*self.plus_side_q) debit_results = self.apply_aggregation(queryset, group_by) if self._debit_and_credit: queryset = main_queryset.all() if kwargs_filters: queryset = queryset.filter(**kwargs_filters) if q_filters: queryset = queryset.filter(*q_filters) if self.minus_side_q: queryset = queryset.filter(*self.minus_side_q) credit_results = self.apply_aggregation(queryset, group_by) return debit_results, credit_results def get_queryset(self): queryset = self.queryset if self.base_q_filters: queryset = queryset.filter(*self.base_q_filters) if self.base_kwargs_filters: queryset = queryset.filter(**self.base_kwargs_filters) return queryset.order_by() def _prepare_required_computations( self, q_filters=None, extra_filters=None, ): values = {} for required_klass in self._require_classes: dep = required_klass( self.plus_side_q, self.minus_side_q, 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} return values def resolve(self, prepared_results, required_computation_results: dict, current_pk, current_row=None) -> float: """ Reponsible for getting the exact data from the prepared value :param prepared_results: the returned data from prepare :param required_computation_results: the returned data from prepare :param current_pk: he value of group by id :param current_row: the row in iteration :return: a solid number or value """ debit_value, credit_value = self.extract_data(prepared_results, current_pk) value = debit_value or 0 - credit_value or 0 return value def do_resolve(self, current_obj, current_row=None): prepared_result = self._cache dependencies_value = self._resolve_dependencies(current_obj) return self.resolve(prepared_result, dependencies_value, current_obj, current_row) def get_dependency_value(self, current_obj, name): """ Get the values of the ReportFields specified in `requires` :param current_obj: the current object which we want the calculation for :param name: the name of the specific dependency you want. :return: a dict containing dependencies names as keys and their calculation as values or a specific value if name is specified. """ values = self._resolve_dependencies(current_obj, name=name) return values.get(name) def _resolve_dependencies(self, current_obj, name=None): dep_results = {} dependencies_value = self._required_prepared_results dependencies_value = dependencies_value or {} needed_values = [name] if name else dependencies_value.keys() for d in needed_values: d_instance = dependencies_value[d]["instance"] dep_results[d] = d_instance.do_resolve(current_obj) return dep_results def extract_data(self, prepared_results, current_obj): group_by = "" if self.prevent_group_by else (self.group_by or self.group_by_custom_querysets) annotation = "__".join([self.calculation_field.lower(), self.calculation_method.name.lower()]) cached_debit, cached_credit = prepared_results cached = [cached_debit, cached_credit] output = [] for results in cached: value = 0 if results: if not group_by: x = list(results.keys())[0] value = results[x] elif self.group_by_custom_querysets: value = results[int(current_obj)][annotation] else: value = results.get(str(current_obj), {}).get(annotation, 0) output.append(value) return output @classmethod def get_full_dependency_list(cls): """ Get the full Hirearchy of dependencies and dependencies dependency. :return: List of dependecies classes """ def get_dependency(field_class): dependencies = field_class._get_required_classes() klasses = [] for klass in dependencies: klasses.append(klass) other = get_dependency(klass) if other: klasses += other return klasses return get_dependency(cls) @classmethod def get_crosstab_field_verbose_name(cls, model, id): """ Construct a verbose name for the crosstab field :param model: the model name :param id: the id of the current crosstab object :return: a verbose string """ if id == "----": return _("The remainder") return f"{cls.verbose_name} {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 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" if pattern == "monthly": month_name = date_filter(date_period[0], "F Y") return f"{cls.verbose_name} {month_name}" elif pattern == "daily": return f"{cls.verbose_name} {date_period[0].strftime(dt_format)}" elif pattern == "weekly": return f' {cls.verbose_name} {_("Week")} {index + 1} {date_period[0].strftime(dt_format)}' elif pattern == "yearly": year = date_filter(date_period[0], "Y") return f"{cls.verbose_name} {year}" return f"{cls.verbose_name} {date_period[0].strftime(dt_format)} - {date_period[1].strftime(dt_format)}" class FirstBalanceField(ComputationField): name = "__fb__" verbose_name = _("opening balance") def prepare( self, q_filters: list | object = None, kwargs_filters: dict = None, main_queryset=None, group_by: str = None, prevent_group_by=None, **kwargs, ): extra_filters = kwargs_filters or {} if self.date_field: from_date_value = extra_filters.get(f"{self.date_field}__gte") extra_filters.pop(f"{self.date_field}__gte", None) extra_filters[f"{self.date_field}__lt"] = from_date_value return super(FirstBalanceField, self).prepare( q_filters, kwargs_filters, main_queryset, group_by, prevent_group_by, **kwargs ) def resolve(self, prepared_results, required_computation_results: dict, current_pk, current_row=None) -> float: if not self.date_field: return 0 return super().resolve(prepared_results, required_computation_results, current_pk, current_row) field_registry.register(FirstBalanceField) class TotalReportField(ComputationField): name = "__total__" verbose_name = _("Sum of value") requires = ["__debit__", "__credit__"] field_registry.register(TotalReportField) class BalanceReportField(ComputationField): name = "__balance__" verbose_name = _("Closing Total") requires = ["__fb__"] def resolve(self, prepared_results, required_computation_results: dict, current_pk, current_row=None) -> float: result = super().resolve(prepared_results, required_computation_results, current_pk, current_row) fb = required_computation_results.get("__fb__") or 0 return result + fb field_registry.register(BalanceReportField) class PercentageToTotalBalance(ComputationField): requires = [BalanceReportField] name = "__percent_to_total_balance__" verbose_name = _("%") prevent_group_by = True def resolve(self, prepared_results, required_computation_results: dict, current_pk, current_row=None) -> float: result = super().resolve(prepared_results, required_computation_results, current_pk, current_row) return required_computation_results.get("__balance__") / result * 100 class CreditReportField(ComputationField): name = "__credit__" verbose_name = _("Credit") def resolve(self, prepared_results, required_computation_results: dict, current_pk, current_row=None) -> float: debit_value, credit_value = self.extract_data(prepared_results, current_pk) return credit_value field_registry.register(CreditReportField) @field_registry.register class DebitReportField(ComputationField): name = "__debit__" verbose_name = _("Debit") def resolve(self, prepared_results, required_computation_results: dict, current_pk, current_row=None) -> float: debit_value, credit_value = self.extract_data(prepared_results, current_pk) return debit_value @field_registry.register class CreditQuantityReportField(ComputationField): name = "__credit_quantity__" verbose_name = _("Credit QTY") calculation_field = "quantity" is_summable = False def resolve(self, prepared_results, required_computation_results: dict, current_pk, current_row=None) -> float: debit_value, credit_value = self.extract_data(prepared_results, current_pk) return credit_value @field_registry.register class DebitQuantityReportField(ComputationField): name = "__debit_quantity__" calculation_field = "quantity" verbose_name = _("Debit QTY") is_summable = False def resolve(self, prepared_results, required_computation_results: dict, current_pk, current_row=None) -> float: debit_value, credit_value = self.extract_data(prepared_results, current_pk) return debit_value class TotalQTYReportField(ComputationField): name = "__total_quantity__" verbose_name = _("Total QTY") calculation_field = "quantity" is_summable = False field_registry.register(TotalQTYReportField) class FirstBalanceQTYReportField(FirstBalanceField): name = "__fb_quantity__" verbose_name = _("Opening QTY") calculation_field = "quantity" is_summable = False field_registry.register(FirstBalanceQTYReportField) class BalanceQTYReportField(ComputationField): name = "__balance_quantity__" verbose_name = _("Closing QTY") calculation_field = "quantity" requires = ["__fb_quantity__"] is_summable = False def resolve(self, prepared_results, required_computation_results: dict, current_pk, current_row=None) -> float: result = super().resolve(prepared_results, required_computation_results, current_pk, current_row) fb = required_computation_results.get("__fb_quantity__") or 0 return result + fb field_registry.register(BalanceQTYReportField) 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) ================================================ FILE: slick_reporting/form_factory.py ================================================ import warnings # warn deprecated warnings.warn( "slick_reporting.form_factory is deprecated. Use slick_reporting.forms instead", Warning, stacklevel=2, ) from .forms import * # noqa ================================================ FILE: slick_reporting/forms.py ================================================ 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, get_field_from_query_text TIME_SERIES_CHOICES = ( ("monthly", _("Monthly")), ("weekly", _("Weekly")), ("annually", _("Yearly")), ("daily", _("Daily")), ) def default_formfield_callback(f, **kwargs): kwargs["required"] = False kwargs["help_text"] = "" return f.formfield(**kwargs) def get_crispy_helper( foreign_keys_map=None, crosstab_model=None, crosstab_key_name=None, crosstab_display_compute_remainder=False, add_date_range=True, ): from crispy_forms.helper import FormHelper from crispy_forms.layout import Column, Layout, Div, Row, Field foreign_keys_map = foreign_keys_map or [] helper = FormHelper() helper.form_class = "form-horizontal" helper.label_class = "col-sm-2 col-md-2 col-lg-2" helper.field_class = "col-sm-10 col-md-10 col-lg-10" helper.form_tag = False helper.disable_csrf = True helper.render_unmentioned_fields = True helper.layout = Layout() if add_date_range: helper.layout.fields.append( Row( Column(Field("start_date"), css_class="col-sm-6"), Column(Field("end_date"), css_class="col-sm-6"), css_class="raReportDateRange", ), ) filters_container = Div(css_class="mt-20", style="margin-top:20px") # first add the crosstab model and its display reimder then the rest of the fields if crosstab_model: filters_container.append(Field(crosstab_key_name)) if crosstab_display_compute_remainder: filters_container.append(Field("crosstab_compute_remainder")) for k in foreign_keys_map: if k != crosstab_key_name: filters_container.append(Field(k)) helper.layout.fields.append(filters_container) return helper 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) def get_order_by(self, default_field=None): """ Get the order by specified by teh form or the default field if provided :param default_field: :return: tuple of field and direction """ if self.is_valid(): order_field = self.cleaned_data["order_by"] order_field = order_field or default_field if order_field: return self.parse_order_by_field(order_field) return None, None def parse_order_by_field(self, order_field): """ Specify the field and direction :param order_field: the field to order by :return: tuple of field and direction """ if order_field: asc = True if order_field[0:1] == "-": order_field = order_field[1:] asc = False return order_field, not asc return None, None class BaseReportForm: def get_filters(self): raise NotImplementedError( "get_filters() must be implemented in subclass," "should return a tuple of (Q objects, kwargs filter) to be passed to QuerySet.filter()" ) def get_start_date(self): raise NotImplementedError("get_start_date() must be implemented in subclass," "should return a datetime object") def get_end_date(self): raise NotImplementedError("get_end_date() must be implemented in subclass," "should return a datetime object") def get_crosstab_compute_remainder(self): raise NotImplementedError( "get_crosstab_compute_remainder() must be implemented in subclass," "should return a boolean value" ) def get_crosstab_ids(self): raise NotImplementedError( "get_crosstab_ids() must be implemented in subclass," "should return a list of ids to be used for crosstab" ) def get_time_series_pattern(self): raise NotImplementedError( "get_time_series_pattern() must be implemented in subclass," "should return a string value of a valid time series pattern" ) def get_crispy_helper(self): # 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): """ Holds basic function """ def get_start_date(self): return self.cleaned_data.get("start_date") def get_end_date(self): return self.cleaned_data.get("end_date") def get_time_series_pattern(self): return self.cleaned_data.get("time_series_pattern") def get_filters(self): """ Get the foreign key filters for report queryset, excluding crosstab ids, handled by `get_crosstab_ids()` :return: a dicttionary of filters to be used with QuerySet.filter(**returned_value) """ _values = {} if self.is_valid(): fk_keys = getattr(self, "foreign_keys", []) if fk_keys: fk_keys = fk_keys.items() for key, field in fk_keys: if key in self.cleaned_data and not key == self.crosstab_key_name: val = self.cleaned_data[key] if val: val = [x for x in val.values_list("pk", flat=True)] _values["%s__in" % key] = val return None, _values @cached_property def crosstab_key_name(self): # todo get the model more accurately """ return the actual foreignkey field name by simply adding an '_id' at the end. This is hook is to customize this naieve approach. :return: key: a string that should be in self.cleaned_data """ if self.crosstab_field_klass: return self.crosstab_field_klass.attname return f"{self.crosstab_model}_id" def get_crosstab_ids(self): """ Get the crosstab ids so they can be sent to the report generator. :return: """ if self.crosstab_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): return self.cleaned_data.get("crosstab_compute_remainder", True) def get_crispy_helper(self, foreign_keys_map=None, crosstab_model=None, **kwargs): return get_crispy_helper( self.foreign_keys, crosstab_model=getattr(self, "crosstab_model", None), crosstab_key_name=getattr(self, "crosstab_key_name", None), crosstab_display_compute_remainder=getattr(self, "crosstab_display_compute_remainder", False), add_date_range=self.add_start_date or self.add_end_date, **kwargs, ) def _default_foreign_key_widget(f_field): return { "form_class": forms.ModelMultipleChoiceField, "required": False, } def report_form_factory( model, crosstab_model=None, display_compute_remainder=True, fkeys_filter_func=None, foreign_key_widget_func=None, excluded_fields=None, initial=None, required=None, show_time_series_selector=False, time_series_selector_choices=None, time_series_selector_default="", time_series_selector_label=None, time_series_selector_allow_empty=False, add_start_date=True, add_end_date=True, ): """ Create a Report Form based on the report_model passed by 1. adding a start_date and end_date fields 2. extract all ForeignKeys on the report_model :param model: the report_model :param crosstab_model: crosstab model if any :param display_compute_remainder: relevant only if crosstab_model is specified. Control if we show the check to display the rest. :param fkeys_filter_func: a receives an OrderedDict of Foreign Keys names and their model field instances found on the model, return the OrderedDict that would be used :param foreign_key_widget_func: receives a Field class return the used widget like this {'form_class': forms.ModelMultipleChoiceField, 'required': False, } :param excluded_fields: a list of fields to be excluded from the report form :param initial a dict for fields initial :param required a list of fields that should be marked as required :return: """ crosstab_field_related_name = "" 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) # gather foreign keys initial = initial or {} required = required or [] fkeys_map = get_foreign_keys(model) excluded_fields = excluded_fields or [] for excluded in excluded_fields: del fkeys_map[excluded] fkeys_map = fkeys_filter_func(fkeys_map) fkeys_list = [] fields = OrderedDict() if add_start_date: fields["start_date"] = forms.DateTimeField( required=False, label=_("From date"), initial=initial.get("start_date", "") or app_settings.SLICK_REPORTING_SETTINGS["DEFAULT_START_DATE_TIME"], widget=forms.DateTimeInput(attrs={"autocomplete": "off"}), ) if add_end_date: fields["end_date"] = forms.DateTimeField( required=False, label=_("To date"), initial=initial.get("end_date", "") or app_settings.SLICK_REPORTING_SETTINGS["DEFAULT_END_DATE_TIME"], widget=forms.DateTimeInput(attrs={"autocomplete": "off"}), ) if show_time_series_selector: time_series_choices = list(TIME_SERIES_CHOICES) if time_series_selector_allow_empty: time_series_choices.insert(0, ("", "---------")) fields["time_series_pattern"] = forms.ChoiceField( required=False, initial=time_series_selector_default, label=time_series_selector_label or _("Period Pattern"), choices=time_series_selector_choices or TIME_SERIES_CHOICES, ) for name, f_field in fkeys_map.items(): fkeys_list.append(name) field_attrs = foreign_key_widget_func(f_field) if name in required: field_attrs["required"] = True field_attrs["initial"] = initial.get(name, "") fields[name] = f_field.formfield(**field_attrs) 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, forms.BaseForm, ) new_form = type( "ReportForm", bases, { "base_fields": fields, "_fkeys": fkeys_list, "foreign_keys": fkeys_map, "crosstab_model": crosstab_model, "crosstab_display_compute_remainder": display_compute_remainder, "crosstab_field_related_name": crosstab_field_related_name, "crosstab_field_klass": crosstab_field_klass, "add_start_date": add_start_date, "add_end_date": add_end_date, }, ) return new_form ================================================ FILE: slick_reporting/generator.py ================================================ import datetime import logging from dataclasses import dataclass from inspect import isclass from django.core.exceptions import ImproperlyConfigured, FieldDoesNotExist from django.db.models import Q, ForeignKey from .app_settings import SLICK_REPORTING_DEFAULT_CHARTS_ENGINE from .fields import ComputationField from .helpers import get_field_from_query_text from .registry import field_registry from . import app_settings logger = logging.getLogger(__name__) @dataclass class Chart: title: str type: str data_source: list title_source: list plot_total: bool = False stacking: bool = False # only for highcharts engine: str = "" entryPoint: str = "" COLUMN = "column" LINE = "line" PIE = "pie" BAR = "bar" AREA = "area" def to_dict(self): return dict( title=self.title, type=self.type, data_source=self.data_source, title_source=self.title_source, plot_total=self.plot_total, engine=self.engine, entryPoint=self.entryPoint, stacking=self.stacking, ) class ReportGeneratorAPI: report_model = None """The main model where data is """ table_name = "" """If set, a dynamic model will be created from this database table name""" queryset = None """If set, the report will use this queryset instead of the report_model""" """ Class to generate a Json Object containing report data. """ date_field = "" """Main date field to use whenever date filter is needed""" start_date_field_name = None """If set, the report will use this field to filter the start date, default to date_field""" end_date_field_name = None """If set, the report will use this field to filter the end date, default to date_field""" print_flag = None group_by = None """The field to use for grouping, if not set then the report is expected to be a sub version of the report model""" group_by_custom_querysets = None """A List of querysets representing different group by options""" group_by_custom_querysets_column_verbose_name = None columns = None """A list of column names. Columns names can be 1. A Computation Field 2. If group_by is set, then any field on the group_by model 3. If group_by is not set, then any field name on the report_model / queryset 4. A callable on the generator 5. Special __time_series__, and __crosstab__ Those can be use to control the position of the time series inside the columns, defaults it's appended at the end Example: columns = ['product_id', '__time_series__', 'col_b'] Same is true with __crosstab__ You can customize aspects of the column by adding it as a tuple like this ('field_name', dict(verbose_name=_('My Enhanced Verbose_name')) """ time_series_pattern = "" """ If set the Report will compute a time series. Possible options are: daily, weekly, semimonthly, monthly, quarterly, semiannually, annually and custom. if `custom` is set, you'd need to override `get_custom_time_series_dates` """ time_series_columns = None """ a list of Calculation Field names which will be included in the series calculation. Example: ['__total__', '__total_quantity__'] with compute those 2 fields for all the series """ time_series_custom_dates = None """ Used with `time_series_pattern` set to 'custom' It's a list of tuple, each tuple represent start date & end date Example: [ (start_date_1, end_date_1), (start_date_2, end_date_2), ....] """ crosstab_model = None # deprecated crosstab_field = None """ If set, a cross tab over this model selected ids (via `crosstab_ids`) """ crosstab_columns = None """The computation fields which will be computed for each crosstab-ed ids """ crosstab_ids = None """A list is the ids to create a crosstab report on""" crosstab_ids_custom_filters = None crosstab_compute_remainder = True """Include an an extra crosstab_columns for the outer group ( ie: all expects those `crosstab_ids`) """ limit_records = None """Serves are a main limit to the returned data of the report_model. Can be beneficial if the results may be huge. """ swap_sign = False crosstab_precomputed = False """If True, crosstab reads pre-computed values from the database instead of aggregating raw data. In this mode, crosstab_columns should be a list of DB column name strings (not ComputationField classes).""" class ReportGenerator(ReportGeneratorAPI, object): """ The main class responsible generating the report and managing the flow """ field_registry_class = field_registry """You can have a custom computation field locator! It only needs a `get_field_by_name(string)` and returns a ReportField`""" def __init__( self, report_model=None, main_queryset=None, start_date=None, end_date=None, date_field=None, q_filters=None, 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, time_series_custom_dates=None, crosstab_field=None, crosstab_columns=None, crosstab_ids=None, crosstab_ids_custom_filters=None, crosstab_compute_remainder=None, crosstab_precomputed=None, swap_sign=False, show_empty_records=None, print_flag=False, doc_type_plus_list=None, doc_type_minus_list=None, limit_records=False, format_row_func=None, container_class=None, start_date_field_name=None, end_date_field_name=None, table_name=None, ): """ :param report_model: Main model containing the data :param main_queryset: Default to report_model.objects :param start_date: :param end_date: :param date_field: :param q_filters: :param kwargs_filters: :param group_by: :param columns: :param time_series_pattern: :param time_series_columns: :param crosstab_model: :param crosstab_columns: :param crosstab_ids: :param crosstab_compute_remainder: :param swap_sign: :param show_empty_records: :param base_model: :param print_flag: :param doc_type_plus_list: :param doc_type_minus_list: :param limit_records: """ from .app_settings import ( SLICK_REPORTING_DEFAULT_START_DATE, SLICK_REPORTING_DEFAULT_END_DATE, ) super().__init__() _table_name = table_name or self.table_name if _table_name and not (report_model or self.report_model): from .dynamic_model import get_dynamic_model report_model = get_dynamic_model(_table_name) self.report_model = self.report_model or report_model if self.queryset is None: self.queryset = main_queryset if not self.report_model and self.queryset is None: raise ImproperlyConfigured("report_model or queryset must be set on a class level or via init") main_queryset = self.report_model.objects if self.queryset is None else self.queryset self.start_date = start_date or datetime.datetime.combine( SLICK_REPORTING_DEFAULT_START_DATE.date(), SLICK_REPORTING_DEFAULT_START_DATE.time(), ) self.end_date = end_date or datetime.datetime.combine( SLICK_REPORTING_DEFAULT_END_DATE.date(), SLICK_REPORTING_DEFAULT_END_DATE.time(), ) self.date_field = self.date_field or date_field self.start_date_field_name = self.start_date_field_name or start_date_field_name or self.date_field self.end_date_field_name = self.end_date_field_name or end_date_field_name or self.date_field self.q_filters = q_filters or [] self.kwargs_filters = kwargs_filters or {} self.crosstab_field = self.crosstab_field or crosstab_field self.crosstab_columns = crosstab_columns or self.crosstab_columns or [] self.crosstab_ids = self.crosstab_ids or crosstab_ids or [] self.crosstab_ids_custom_filters = self.crosstab_ids_custom_filters or crosstab_ids_custom_filters or [] self.crosstab_compute_remainder = ( self.crosstab_compute_remainder if crosstab_compute_remainder is None else crosstab_compute_remainder ) self.crosstab_precomputed = self.crosstab_precomputed if crosstab_precomputed is None else crosstab_precomputed self._precomputed_crosstab_data = {} if self.crosstab_precomputed: if not self.crosstab_field: raise ImproperlyConfigured("crosstab_precomputed requires crosstab_field to be set") if not self.crosstab_columns: raise ImproperlyConfigured("crosstab_precomputed requires crosstab_columns to be set") 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 # 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_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.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 ): raise ImproperlyConfigured( f"date_field or [start_date_field_name and end_date_field_name] must " f"be set for {container_class or self}" ) self._prepared_results = {} self.report_fields_classes = {} self._report_fields_dependencies = { "time_series": {}, "crosstab": {}, "normal": {}, } self.existing_dependencies = {"series": [], "matrix": [], "normal": []} self.print_flag = print_flag or self.print_flag # todo validate columns is not empty (if no time series / cross tab) if self.group_by: try: self.group_by_field = get_field_from_query_text(self.group_by, self.report_model) except (IndexError, AttributeError): raise ImproperlyConfigured( f"Can not find group_by field:{self.group_by} in report_model {self.report_model} " ) if "__" not in self.group_by: self.group_by_field_attname = self.group_by_field.attname else: self.group_by_field_attname = self.group_by else: self.group_by_field_attname = None # 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.swap_sign = self.swap_sign or swap_sign self.limit_records = self.limit_records or limit_records # todo delete this self.show_empty_records = False # show_empty_records if show_empty_records else self.show_empty_records # Preparing actions self._parse() self.main_queryset = self.prepare_queryset(main_queryset) self._prepare_report_dependencies() def _get_fk_group_by_queryset(self, filtered_qs): """Return the related-model queryset for a ForeignKey group_by field.""" ids = filtered_qs.values_list(self.group_by_field_attname).distinct() # uses the same logic that is in Django's query.py when fields is empty in values() call concrete_fields = [f.name for f in self.group_by_field.related_model._meta.concrete_fields] # add database columns that are not already in concrete_fields final_fields = concrete_fields + list(set(self.get_database_columns()) - set(concrete_fields)) return self.group_by_field.related_model.objects.filter( **{f"{self.group_by_field.target_field.name}__in": ids} ).values(*final_fields) def prepare_queryset(self, queryset): if self.crosstab_precomputed: self._build_precomputed_crosstab_data(queryset) self._crosstab_parsed_columns = self.get_crosstab_parsed_columns() if not self.group_by: return [{}] filtered_qs = self._apply_queryset_options(queryset) if type(self.group_by_field) is ForeignKey: return self._get_fk_group_by_queryset(filtered_qs) return filtered_qs.values(self.group_by_field_attname).distinct() if self.group_by_custom_querysets: return [{"__index__": i} for i, v in enumerate(self.group_by_custom_querysets)] elif self.group_by: main_queryset = self._apply_queryset_options(queryset) if type(self.group_by_field) is ForeignKey: return self._get_fk_group_by_queryset(main_queryset) else: return main_queryset.distinct().values(self.group_by_field_attname) return [{}] def _remove_order(self, main_queryset): """ Remove order_by from the main queryset :param main_queryset: :return: """ # if main_queryset.query.order_by: main_queryset = main_queryset.order_by() return main_queryset def _apply_queryset_options(self, query, fields=None): """ Apply the filters to the main queryset which will computed results be mapped to :param query: :param fields: :return: """ filters = {} if self.date_field: filters = { f"{self.start_date_field_name}__gt": self.start_date, f"{self.end_date_field_name}__lte": self.end_date, } filters.update(self.kwargs_filters) 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() def _apply_precomputed_queryset_options(self, query): """Apply filters for precomputed crosstab data fetch, using __gte/__lte boundaries.""" filters = {} if self.date_field: filters = { f"{self.start_date_field_name}__gte": self.start_date, f"{self.end_date_field_name}__lte": self.end_date, } filters.update(self.kwargs_filters) if filters: query = query.filter(**filters) if self.q_filters: query = query.filter(*self.q_filters) return query.values() def _build_precomputed_crosstab_data(self, queryset): """Pre-fetch all rows and build the precomputed crosstab lookup dict.""" filtered_qs = self._apply_precomputed_queryset_options(queryset) rows = filtered_qs.values(self.group_by_field_attname, self.crosstab_field, *self.crosstab_columns) crosstab_values_set = set() for row in rows: group_key = str(row[self.group_by_field_attname]) crosstab_val = str(row[self.crosstab_field]) crosstab_values_set.add(row[self.crosstab_field]) if group_key not in self._precomputed_crosstab_data: self._precomputed_crosstab_data[group_key] = {} self._precomputed_crosstab_data[group_key][crosstab_val] = {col: row[col] for col in self.crosstab_columns} if not self.crosstab_ids: self.crosstab_ids = sorted(crosstab_values_set) def _construct_crosstab_filter(self, col_data, queryset_filters=None): """ In charge of adding the needed crosstab filter, specific to the case of is_remainder or not :param col_data: :return: """ if queryset_filters: return queryset_filters[0], queryset_filters[1] if "__" in col_data["crosstab_field"]: column_name = col_data["crosstab_field"] else: 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})] else: filters = [Q(**{f"{column_name}": col_data["id"]})] return filters, {} def _prepare_report_dependencies(self): from .fields import ComputationField all_columns = ( ("normal", self._parsed_columns), ("time_series", self._time_series_parsed_columns), ("crosstab", self._crosstab_parsed_columns), ) for window, window_cols in all_columns: for col_data in window_cols: klass = col_data["ref"] 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 # resolve the value for its parent avoiding extra database call fields_on_report = [ x for x in window_cols if x["ref"] in dependencies_names and ( ( window == "time_series" and x.get("start_date", "") == col_data.get("start_date", "") and x.get("end_date") == col_data.get("end_date") ) or window == "crosstab" and x.get("id") == col_data.get("id") ) ] for field in fields_on_report: 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, ComputationField)): continue if self._report_fields_dependencies[window].get(name, False): continue report_class = klass( self.doc_type_plus_list, self.doc_type_minus_list, group_by=self.group_by, report_model=self.report_model, date_field=self.date_field, queryset=self.queryset, group_by_custom_querysets=self.group_by_custom_querysets, ) q_filters = None date_filter = {} if self.start_date_field_name: date_filter[f"{self.start_date_field_name}__gte"] = col_data.get("start_date", self.start_date) if self.end_date_field_name: date_filter[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": q_filters, kw_filters = col_data["queryset_filters"] date_filter.update(kw_filters) report_class.init_preparation(q_filters, date_filter) self.report_fields_classes[name] = report_class # @staticmethod def get_primary_key_name(self, model): if self.group_by_custom_querysets: return "__index__" for field in model._meta.fields: if field.primary_key: return field.attname return "" def _get_record_data(self, obj, columns): """ the function is run for every obj in the main_queryset :param obj: current row :param: columns: The columns we iterate on :return: a dict object containing all needed data """ data = {} group_by_val = None if self.group_by_custom_querysets: group_by_val = str(obj["__index__"]) elif self.group_by: if self.group_by_field.related_model and "__" not in self.group_by: primary_key_name = self.get_primary_key_name(self.group_by_field.related_model) else: primary_key_name = self.group_by_field_attname column_data = obj.get(primary_key_name, obj.get("id")) group_by_val = str(column_data) for window, window_cols in columns: for col_data in window_cols: name = col_data["name"] if col_data.get("source", "") == "precomputed_crosstab": crosstab_val = col_data["crosstab_value"] crosstab_col = col_data["crosstab_column"] group_data = self._precomputed_crosstab_data.get(group_by_val, {}) data[name] = group_data.get(crosstab_val, {}).get(crosstab_col, 0) elif col_data.get("source", "") == "attribute_field": 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 (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] # the computation field is being asked from another computation field that requires it. value = computation_class.get_dependency_value(group_by_val, col_data["ref"].name) else: try: computation_class = self.report_fields_classes[name] except KeyError: continue value = computation_class.do_resolve(group_by_val, data) if self.swap_sign: value = -value data[name] = value else: data[name] = obj.get(name, "") return data def get_report_data(self): main_queryset = self.main_queryset[: self.limit_records] if self.limit_records else self.main_queryset all_columns = ( ("normal", self._parsed_columns), ("time_series", self._time_series_parsed_columns), ("crosstab", self._crosstab_parsed_columns), ) get_record_data = self._get_record_data format_row = self.format_row data = [format_row(get_record_data(obj, all_columns)) for obj in main_queryset] return data def _default_format_row(self, row_obj): """ Hook where you can format row values like properly format a date :param row_obj: :return: """ return row_obj @staticmethod def check_columns( cls, columns, group_by, report_model, container_class=None, group_by_custom_querysets=None, ): """ Check and parse the columns, throw errors in case an item in the columns cant not identified :param columns: List of columns :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: columns.insert(0, "__index__") if group_by: try: 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 " f"report_model: `{report_model}`" ) if group_by_field.is_relation: group_by_model = group_by_field.related_model else: group_by_model = report_model parsed_columns = [] for col in columns: options = {} if type(col) is tuple: col, options = col if col in ["__time_series__", "__crosstab__"]: # These are placeholder not real computation field continue magic_field_class = None attribute_field = None is_container_class_attribute = False 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, ComputationField): magic_field_class = col try: magic_field_class = magic_field_class or field_registry.get_field_by_name(col) except KeyError: magic_field_class = None if attribute_field: 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", "ref": attribute_field, "type": "text", } elif magic_field_class: # a magic field col_data = { "name": magic_field_class.name, "verbose_name": magic_field_class.verbose_name, "source": "magic_field", "ref": magic_field_class, "type": magic_field_class.type, "is_summable": magic_field_class.is_summable, } else: # A database field if group_by_custom_querysets and col == "__index__": # group by custom queryset special case: which is the index col_data = { "name": col, "verbose_name": cls.group_by_custom_querysets_column_verbose_name, "source": "database", "ref": "", "type": "text", } col_data.update(options) parsed_columns.append(col_data) continue model_to_use = group_by_model if group_by and "__" not in group_by 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 model_to_use = related_model if related_model else model_to_use try: if "__" in col: # A traversing link order__client__email field = get_field_from_query_text(col, model_to_use) else: field = model_to_use._meta.get_field(col) except FieldDoesNotExist: field = getattr(container_class, col, False) if not field: raise FieldDoesNotExist( f'Field "{col}" not found either as an attribute to the generator class {cls}, ' f'{f"Container class {container_class}," if container_class else ""}' f'or a computation field, or a database column for the model "{model_to_use}"' ) col_data = { "name": col, "verbose_name": getattr(field, "verbose_name", col), "source": "database", "ref": field, "type": "choice" if field.choices else field.get_internal_type(), } col_data.update(options) parsed_columns.append(col_data) return parsed_columns def _parse(self): self.parsed_columns = self.check_columns( self, self.columns, self.group_by, self.report_model, self.container_class, self.group_by_custom_querysets, ) self._parsed_columns = list(self.parsed_columns) self._crosstab_parsed_columns = self.get_crosstab_parsed_columns() self._time_series_parsed_columns = self.get_time_series_parsed_columns() def get_database_columns(self): return [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'] def get_list_display_columns(self): columns = self.parsed_columns if self.time_series_pattern: time_series_columns = self.get_time_series_parsed_columns() try: index = self.columns.index("__time_series__") columns[index:index] = time_series_columns except ValueError: columns += time_series_columns if self.crosstab_field: crosstab_columns = self.get_crosstab_parsed_columns() try: index = self.columns.index("__crosstab__") columns[index:index] = crosstab_columns except ValueError: columns += crosstab_columns return columns def get_time_series_parsed_columns(self): """ Return time series columns with all needed data attached :param plain: if True it returns '__total__' instead of '__total_TS011212' :return: List if columns """ _values = [] cols = self.time_series_columns or [] series = self._get_time_series_dates(self.time_series_pattern) for index, dt in enumerate(series): for col in cols: magic_field_class = None if isinstance(col, str): magic_field_class = field_registry.get_field_by_name(col) elif issubclass(col, ComputationField): magic_field_class = col _values.append( { "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), "ref": magic_field_class, "start_date": dt[0], "end_date": dt[1], "source": "magic_field" if magic_field_class else "", "is_summable": magic_field_class.is_summable, } ) # append the crosstab fields, if they exist, on the time_series if self._crosstab_parsed_columns: for parsed_col in self._crosstab_parsed_columns: parsed_col = parsed_col.copy() parsed_col["name"] = parsed_col["name"] + "TS" + dt[1].strftime("%Y%m%d") parsed_col["start_date"] = dt[0] parsed_col["end_date"] = dt[1] _values.append(parsed_col) return _values def get_time_series_field_verbose_name(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 (which is name + the end date %Y%m%d) :param computation_class: the computation field_name :param date_period: a tuple of (start_date, end_date) :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) def get_custom_time_series_dates(self): """ Hook to get custom , maybe separated date periods :return: [ (date1,date2) , (date3,date4), .... ] """ return self.time_series_custom_dates or [] def _get_time_series_dates(self, series=None, start_date=None, end_date=None): from dateutil.relativedelta import relativedelta series = series or self.time_series_pattern start_date = start_date or self.start_date end_date = end_date or self.end_date _values = [] if series: if series == "daily": time_delta = datetime.timedelta(days=1) elif series == "weekly": time_delta = relativedelta(weeks=1) elif series == "bi-weekly": time_delta = relativedelta(weeks=2) elif series == "monthly": time_delta = relativedelta(months=1) elif series == "quarterly": time_delta = relativedelta(months=3) elif series == "semiannually": time_delta = relativedelta(months=6) elif series == "annually": time_delta = relativedelta(years=1) elif series == "custom": return self.get_custom_time_series_dates() else: raise NotImplementedError(f'"{series}" is not implemented for time_series_pattern') done = False while not done: to_date = start_date + time_delta _values.append((start_date, to_date)) start_date = to_date if to_date >= end_date: done = True return _values def get_crosstab_parsed_columns(self): """ Return a list of the columns analyzed , with reference to computation field and everything :return: """ if self.crosstab_precomputed: return self._get_precomputed_crosstab_parsed_columns() report_columns = self.crosstab_columns or [] ids = list(self.crosstab_ids) or list(self.crosstab_ids_custom_filters) if self.crosstab_compute_remainder and not self.crosstab_ids_custom_filters: ids.append("----") output_cols = [] ids_length = len(ids) - 1 for counter, crosstab_id in enumerate(ids): queryset_filters = None if self.crosstab_ids_custom_filters: queryset_filters = crosstab_id crosstab_id = counter for col in report_columns: magic_field_class = None if isinstance(col, str): magic_field_class = field_registry.get_field_by_name(col) elif issubclass(col, ComputationField): magic_field_class = col crosstab_column = { "name": f"{magic_field_class.name}CT{crosstab_id}", "original_name": magic_field_class.name, "verbose_name": self.get_crosstab_field_verbose_name( magic_field_class, self.crosstab_field, crosstab_id ), "ref": magic_field_class, "id": crosstab_id, "crosstab_field": self.crosstab_field, "is_remainder": counter == ids_length if self.crosstab_compute_remainder else False, "source": "magic_field" if magic_field_class else "", "is_summable": magic_field_class.is_summable, "computation_flag": "crosstab", # a flag, todo find a better way probably } crosstab_column["queryset_filters"] = self._construct_crosstab_filter(crosstab_column, queryset_filters) output_cols.append(crosstab_column) return output_cols def _get_precomputed_crosstab_parsed_columns(self): """Build column metadata for precomputed crosstab from discovered values.""" columns = [] for crosstab_val in self.crosstab_ids: sanitized = _sanitize_crosstab_key(crosstab_val) for col_name in self.crosstab_columns: columns.append( { "name": f"{col_name}CT{sanitized}", "original_name": col_name, "verbose_name": f"{col_name} {crosstab_val}", "source": "precomputed_crosstab", "is_summable": True, "ref": "", "crosstab_value": str(crosstab_val), "crosstab_column": col_name, "type": "number", "visible": True, } ) return columns def get_crosstab_field_verbose_name(self, computation_class, model, id): """ Hook to change the crosstab field verbose name, default it delegate this function to the ReportField :param computation_class: ReportField Class :param model: the model name as string :param id: the current crosstab id :return: a verbose string """ return computation_class.get_crosstab_field_verbose_name(model, id) def get_metadata(self): """ A hook to send data about the report for front end which can later be used in charting :return: """ time_series_columns = self.get_time_series_parsed_columns() crosstab_columns = self.get_crosstab_parsed_columns() 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], "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], } return metadata def get_columns_data(self): """ Hook to get the columns information to front end :param columns: :return: """ columns = self.get_list_display_columns() data = [] for col in columns: data.append( { "name": col["name"], "computation_field": col.get("original_name", ""), "verbose_name": col["verbose_name"], "visible": col.get("visible", True), "type": col.get("type", "text"), "is_summable": col.get("is_summable", ""), } ) return data def get_full_response( self, data=None, report_slug=None, chart_settings=None, default_chart_title=None, default_chart_engine=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_engine=default_chart_engine ), } return data @staticmethod def get_chart_settings(chart_settings=None, default_chart_title=None, chart_engine=None): """ Ensure the sane settings are passed to the front end. ? """ chart_engine = chart_engine or SLICK_REPORTING_DEFAULT_CHARTS_ENGINE output = [] chart_settings = chart_settings or [] report_title = default_chart_title or "" for i, chart in enumerate(chart_settings): if type(chart) is Chart: chart = chart.to_dict() chart["id"] = chart.get("id", f"{i}") chart["engine_name"] = chart.get("engine_name", chart_engine) chart_type = chart.get("type", "line") if chart_type == "column" and chart["engine_name"] == "chartsjs": chart["type"] = "bar" if not chart.get("title", False): chart["title"] = report_title chart["entryPoint"] = ( chart.get("entryPoint") or app_settings.SLICK_REPORTING_SETTINGS["CHARTS"][chart["engine_name"]]["entryPoint"] ) chart["stacking"] = chart.get("stacking", False) 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 :param query: :param fields: :return: """ filters = {} if self.date_field: filters = { f"{self.date_field}__gt": self.start_date, f"{self.date_field}__lte": self.end_date, } filters.update(self.kwargs_filters) if filters: query = query.filter(**filters) if fields: return query.values(*fields) return query def _get_record_data(self, obj, columns): """ the function is run for every obj in the main_queryset :param obj: current row :param: columns: The columns we iterate on :return: a dict object containing all needed data """ data = {} 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) else: primary_key_name = self.group_by_field_attname column_data = obj.get(primary_key_name, obj.get("id")) group_by_val = str(column_data) for window, window_cols in columns: for col_data in window_cols: name = col_data["name"] if col_data.get("source", "") == "attribute_field": data[name] = col_data["ref"](self, obj, data) # changed line 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 ): 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) else: try: computation_class = self.report_fields_classes[name] except KeyError: continue value = computation_class.do_resolve(group_by_val, data) if self.swap_sign: value = -value data[name] = value else: data[name] = obj[name] return data def _remove_order(self, main_queryset): return main_queryset def _sanitize_crosstab_key(value): """Sanitize a crosstab value for use in column names. Replace non-alphanumeric chars with underscores.""" import re return re.sub(r"[^a-zA-Z0-9_]", "_", str(value)) ================================================ FILE: slick_reporting/helpers.py ================================================ from collections import OrderedDict from django.conf import settings from django.contrib.contenttypes.fields import GenericForeignKey def get_calculation_annotation(calculation_field, calculation_method): """ Returns the default django annotation @param calculation_field: the field to calculate ex 'value' @param calculation_method: the aggregation method ex: Sum @return: the annotation ex value__sum """ return "__".join([calculation_field.lower(), calculation_method.name.lower()]) def get_foreign_keys(model): """ Scans a model and return an Ordered Dictionary with the foreign keys found :param model: the model to scan :return: Ordered Dict """ from django.db import models fields = model._meta.get_fields() fkeys = OrderedDict() for f in fields: if ( f.is_relation and type(f) is not models.OneToOneRel and type(f) is not models.ManyToOneRel and type(f) is not models.ManyToManyRel and type(f) is not GenericForeignKey ): fkeys[f.attname] = f return fkeys def get_field_from_query_text(path, model): """ return the field of a query text `modelA__modelB__foo_field` would return foo_field on modelsB :param path: :param model: :return: """ relations = path.split("__") _rel = model field = None for i, m in enumerate(relations): field = _rel._meta.get_field(m) if i == len(relations) - 1: return field _rel = field.related_model return field def user_test_function(report_view): """ A default test function return True on DEBUG, otherwise return the user.is_superuser :param report_view: :return: """ if not settings.DEBUG: return report_view.request.user.is_superuser return True ================================================ FILE: slick_reporting/locale/ar/LC_MESSAGES/django.po ================================================ # SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. # msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2026-04-19 11:13+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" "Language: \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 " "&& n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n" #: app_settings.py:88 msgid "Total" msgstr "الإجمالي" #: app_settings.py:89 msgid "Export to CSV" msgstr "تصدير إلى CSV" #: fields.py:355 msgid "The remainder" msgstr "المتبقي" #: fields.py:387 msgid "opening balance" msgstr "الرصيد الافتتاحي" #: fields.py:418 msgid "Sum of value" msgstr "مجموع القيمة" #: fields.py:427 msgid "Closing Total" msgstr "الإجمالي الختامي" #: fields.py:443 msgid "%" msgstr "%" #: fields.py:454 msgid "Credit" msgstr "دائن" #: fields.py:467 msgid "Debit" msgstr "مدين" #: fields.py:477 msgid "Credit QTY" msgstr "الكمية الدائنة" #: fields.py:490 msgid "Debit QTY" msgstr "الكمية المدينة" #: fields.py:500 msgid "Total QTY" msgstr "إجمالي الكمية" #: fields.py:510 msgid "Opening QTY" msgstr "الكمية الافتتاحية" #: fields.py:520 msgid "Closing QTY" msgstr "الكمية الختامية" #: forms.py:12 msgid "Monthly" msgstr "شهري" #: forms.py:13 msgid "Weekly" msgstr "أسبوعي" #: forms.py:14 msgid "Yearly" msgstr "سنوي" #: forms.py:15 msgid "Daily" msgstr "يومي" #: forms.py:280 msgid "From date" msgstr "من تاريخ" #: forms.py:287 msgid "To date" msgstr "إلى تاريخ" #: forms.py:300 msgid "Period Pattern" msgstr "نمط الفترة" #: forms.py:321 msgid "Display the crosstab remainder" msgstr "عرض متبقي الجدول المتقاطع" #: templates/slick_reporting/report.html:9 msgid "Filters" msgstr "المرشحات" #: templates/slick_reporting/report.html:15 msgid "Filter" msgstr "تصفية" #: templates/slick_reporting/report.html:31 msgid "Results" msgstr "النتائج" ================================================ FILE: slick_reporting/locale/de/LC_MESSAGES/django.po ================================================ # SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. # msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2026-04-19 11:28+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" "Language: \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" #: app_settings.py:88 msgid "Total" msgstr "Gesamt" #: app_settings.py:89 msgid "Export to CSV" msgstr "Als CSV exportieren" #: fields.py:355 msgid "The remainder" msgstr "Der Rest" #: fields.py:387 msgid "opening balance" msgstr "Eröffnungssaldo" #: fields.py:418 msgid "Sum of value" msgstr "Wertsumme" #: fields.py:427 msgid "Closing Total" msgstr "Abschlusssumme" #: fields.py:443 msgid "%" msgstr "%" #: fields.py:454 msgid "Credit" msgstr "Haben" #: fields.py:467 msgid "Debit" msgstr "Soll" #: fields.py:477 msgid "Credit QTY" msgstr "Haben-Menge" #: fields.py:490 msgid "Debit QTY" msgstr "Soll-Menge" #: fields.py:500 msgid "Total QTY" msgstr "Gesamtmenge" #: fields.py:510 msgid "Opening QTY" msgstr "Eröffnungsmenge" #: fields.py:520 msgid "Closing QTY" msgstr "Abschlussmenge" #: forms.py:12 msgid "Monthly" msgstr "Monatlich" #: forms.py:13 msgid "Weekly" msgstr "Wöchentlich" #: forms.py:14 msgid "Yearly" msgstr "Jährlich" #: forms.py:15 msgid "Daily" msgstr "Täglich" #: forms.py:280 msgid "From date" msgstr "Von Datum" #: forms.py:287 msgid "To date" msgstr "Bis Datum" #: forms.py:300 msgid "Period Pattern" msgstr "Periodenmuster" #: forms.py:321 msgid "Display the crosstab remainder" msgstr "Kreuztabellen-Rest anzeigen" #: templates/slick_reporting/report.html:9 msgid "Filters" msgstr "Filter" #: templates/slick_reporting/report.html:15 msgid "Filter" msgstr "Filtern" #: templates/slick_reporting/report.html:31 msgid "Results" msgstr "Ergebnisse" ================================================ FILE: slick_reporting/registry.py ================================================ from __future__ import unicode_literals from django.contrib.admin.sites import AlreadyRegistered, NotRegistered class ReportFieldRegistry(object): def __init__(self): super(ReportFieldRegistry, self).__init__() self._registry = {} # holds def register(self, report_field, override=False): """ Register a report_field into the registry, :param report_field: :param override: if True, a report_field will get replaced if found, else it would throw an AlreadyRegistered :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") self._registry[report_field.name] = report_field return report_field def unregister(self, report_field): """ To unregister a Report Field :param report_field: a Report field class or a ReportField Name :return: None """ 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] def get_field_by_name(self, name): if name in self._registry: return self._registry[name] else: raise KeyError( f'{name} is not found in the report field registry. Options are {",".join(self.get_all_report_fields_names())}' ) def get_all_report_fields_names(self): return list(self._registry.keys()) field_registry = ReportFieldRegistry() ================================================ FILE: slick_reporting/static/slick_reporting/slick_reporting.chartsjs.js ================================================ // type / title_source / data_source, // title (function ($) { var COLORS = ['#7cb5ec', '#f7a35c', '#90ee7e', '#7798BF', '#aaeeee', '#ff0066', '#eeaaee', '#55BF3B', '#DF5353', '#7798BF', '#aaeeee']; let _chart_cache = {}; function is_time_series(response, chartOptions) { if (chartOptions.time_series_support === false) return false; return response['metadata']['time_series_pattern'] !== ""; } function is_crosstab(response, chartOptions) { return response['metadata']['crosstab_model'] || ''; } function getTimeSeriesColumnNames(response) { return response['metadata']['time_series_column_names']; } function createChartObject(response, chartOptions, extraOptions) { let extractedData = extractDataFromResponse(response, chartOptions); // Chart.js has no 'area' type; use 'line' with fill let chartType = chartOptions.type; let fillArea = false; if (chartType === 'area') { chartType = 'line'; fillArea = true; } let chartObject = { type: chartType, 'data': { labels: extractedData.labels, datasets: extractedData.datasets, }, 'options': { 'responsive': true, plugins: { title: { display: true, text: chartOptions.title, }, tooltip: { mode: 'index', }, }, } }; if (chartOptions.type === 'pie') { chartObject['options'] = { responsive: true, maintainAspectRatio: true, aspectRatio: 2, plugins: { title: { display: true, text: chartOptions.title, }, }, } } if (chartOptions.stacking === true) { chartObject['options']['scales'] = { y: {stacked: true}, x: {stacked: true}, } } if (fillArea) { chartObject.data.datasets.forEach(function (ds) { ds.fill = true; }); } return chartObject } function getGroupByLabelAndSeries(response, chartOptions) { let legendResults = []; let datasetData = []; let dataFieldName = chartOptions['data_source']; let titleFieldName = chartOptions['title_source']; for (let i = 0; i < response.data.length; i++) { let row = response.data[i]; if (titleFieldName !== '') { let txt = row[titleFieldName]; txt = $(txt).text() || txt; // the title is an "); } let cache_key = $.slick_reporting.get_xpath($elem) + ":" + data.report_slug + ':' + chartOptions.id; try { let existing_chart = _chart_cache[cache_key]; if (typeof (existing_chart) !== 'undefined') { existing_chart.destroy(); } } catch (e) { console.error(e) } let chartObject = $.slick_reporting.chartsjs.createChartObject(data, chartOptions); let canvas = $elem.find('canvas')[0]; try { _chart_cache[cache_key] = new Chart(canvas, chartObject); } catch (e) { console.error(e); $elem.find('canvas').remove(); } } if (typeof ($.slick_reporting) === 'undefined') { $.slick_reporting = {} } $.slick_reporting.chartsjs = { getGroupByLabelAndSeries: getGroupByLabelAndSeries, createChartObject: createChartObject, displayChart: displayChart, defaults: { // normalStackedTooltipFormatter: normalStackedTooltipFormatter, messages: { noData: 'No Data to display ... :-/', total: 'Total', percent: 'Percent', }, credits: { // text: 'RaSystems.io', // href: 'https://rasystems.io' }, notify_error: function () { }, enable3d: false, } }; }(jQuery)); ================================================ FILE: slick_reporting/static/slick_reporting/slick_reporting.datatable.js ================================================ /** * Created by ramez on 2/5/15. * A wrapper around Datatables.net * */ (function ($) { let _cache = {}; let _instances = {} function constructTable(css_class, cols, cols_names, add_footer, total_verbose, total_fields, data) { // Construct an HTML table , header and footer , without a body as it is filled by th datatable.net plugin cols = typeof cols != 'undefined' ? cols : false; cols_names = typeof cols_names != 'undefined' ? cols_names : cols; let return_val = ``; let header_th = ''; let footer_th = ''; let footer_colspan = 0; let stop_colspan_detection = false; let totals_container = $.slick_reporting.calculateTotalOnObjectArray(data, total_fields); if (data.length <= 1) { add_footer = false; } for (let i = 0; i < cols.length; i++) { let col_name = cols[i].name; header_th += ``; if (total_fields.indexOf(col_name) !== -1) { stop_colspan_detection = true; } if (!stop_colspan_detection) { footer_colspan += 1; } else { let column_total = totals_container[col_name] if (!(column_total || column_total === 0)) { column_total = '' } footer_th += ``; } } let footer = ''; if (add_footer && stop_colspan_detection) { footer = '' + footer_th + ''; } return_val = return_val + header_th + `${footer}
${cols_names[i]}${column_total}
' + total_verbose + '
`; return return_val; } function buildAndInitializeDataTable(data, $elem, extraOptions, successFunction) { // Responsible for turning a ReportView Response into a datatable. let opts = $.extend({}, $.slick_reporting.datatable.defaults, extraOptions); opts['datatableContainer'] = $elem; let datatable_container = opts.datatableContainer; let provide_total = true; // typeof provide_total == 'undefined' ? true : provide_total; let total_fields = []; //# frontend_settings.total_fields || []; let column_names = []; for (let i = 0; i < data['columns'].length; i++) { let col = data['columns'][i]; column_names.push(col['verbose_name']); if (col['is_summable'] === true) { total_fields.push(col['name']) } } if (total_fields.length === 0) provide_total = false; datatable_container.html(constructTable( $.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); if (typeof (successFunction) === 'function') { successFunction(data); } } function getDatatableColumns(data) { let columns = []; for (let i = 0; i < data['columns'].length; i++) { let server_data = data['columns'][i]; let col_data = { "data": server_data['name'], 'visible': server_data['visible'], 'title': server_data['verbose_name'] }; columns.push(col_data); } return columns; } function initializeReportDatatable(tableSelector, data, extraOptions) { tableSelector = typeof tableSelector != 'undefined' ? tableSelector : '.datatable'; extraOptions = typeof extraOptions != 'undefined' ? extraOptions : {}; let opts = $.extend({}, $.slick_reporting.datatable.defaults, extraOptions); let dom = typeof (extraOptions.dom) == 'undefined' ? 'lfrtip' : extraOptions.dom; let paging = typeof (extraOptions.paging) == 'undefined' ? true : extraOptions.paging; let ordering = typeof (extraOptions.ordering) == 'undefined' ? true : extraOptions.ordering; let info = typeof (extraOptions.info) == 'undefined' ? true : extraOptions.info; let searching = typeof (extraOptions.searching) == 'undefined' ? true : extraOptions.searching; if (data.data.length === 0) dom = '<"mb-20"t>'; let datatableOptions = $.extend({}, extraOptions['datatableOptions']); datatableOptions.dom = dom; datatableOptions.ordering = ordering; datatableOptions.paging = paging; datatableOptions.info = info; datatableOptions.searching = searching; datatableOptions.sorting = []; datatableOptions.processing = true; datatableOptions.data = data['data']; datatableOptions.columns = getDatatableColumns(data); datatableOptions.initComplete = function (settings, json) { setTimeout(function () { if (opts.enableFixedHeader) { new $.fn.dataTable.FixedHeader(dt, {"zTop": "2001"}); } }, 100); }; _instances[data.report_slug] = $(tableSelector).DataTable(datatableOptions); } $.slick_reporting.datatable = { initializeDataTable: initializeReportDatatable, _cache: _cache, buildAdnInitializeDatatable: buildAndInitializeDataTable, constructTable: constructTable, instances: _instances } }(jQuery)); $.slick_reporting.datatable.defaults = { enableFixedHeader: false, fixedHeaderZindex: 2001, messages: { total: $.slick_reporting.defaults.total_label, }, tableCssClass: 'table table-xxs datatable-basic table-bordered table-striped table-hover ', datatableOptions: { // datatables options sent to its constructor. css_class: 'display' } }; ================================================ FILE: slick_reporting/static/slick_reporting/slick_reporting.highchart.js ================================================ /** * Created by Ramez on 11/20/14. * Updated to support modern Highcharts API (v11+). */ (function ($) { function dataArrayToObject(data, key) { // Turn a data array to an object // Example: // in: [ // {'key': key , 'value': 0}, // {'key': key , 'value': 1}, // {'key': key , 'value': 2}, // ] // out: {'key':key , 'value':[0,1,2]} var output = {}; for (var r = 0; r < data.length; r++) { output[data[r][key]] = data[r]; } return output } let _chart_cache = {}; function normalStackedTooltipFormatter() { var tooltip = '' + this.x + '' + '' + '' + '' + '
' + this.series.name + ': ' + this.point.y + '
{pertoTotal}:' + this.point.percentage.toFixed(2) + ' %
{Total}:' + this.point.stackTotal + '
'; //style="color: '+ this.series.color+'" tooltip = tooltip.format($.slick_reporting.highcharts.defaults.messages); return tooltip } function transform_to_pie(chartObject_series, index, categories) { index = index || 0; let new_series_data = [] chartObject_series.forEach(function (elem, key) { new_series_data.push({ 'name': elem.name, 'y': elem.data[index] }) }) return { 'name': categories[index], 'data': new_series_data } } function createChartObject(response, chartOptions, extraOptions) { // Create the chart Object // First specifying the global defaults then apply the specification from the response try { $.extend(chartOptions, { 'sub_title': '', }); chartOptions.data = response.data; let is_time_series = is_timeseries_support(response, chartOptions); // response.metadata.time_series_pattern || ''; let is_crosstab = is_crosstab_support(response, chartOptions); let chart_type = chartOptions.type; let enable3d = false; let chart_data = {}; let rtl = false; // $.slick_reporting.highcharts.defaults.rtl; if (is_time_series) { chart_data = get_time_series_data(response, chartOptions) } else if (is_crosstab) { chart_data = get_crosstab_data(response, chartOptions) } else { chart_data = get_normal_data(response, chartOptions) } let highchart_object = { chart: { type: '', }, title: { text: chartOptions.title, }, subtitle: { text: chartOptions.sub_title, useHTML: true }, yAxis: { opposite: rtl, }, xAxis: { labels: {enabled: true}, reversed: rtl, }, tooltip: { useHTML: true }, plotOptions: {}, exporting: { allowHTML: true, enabled: true, } }; highchart_object.series = chart_data.series; if (chart_type === 'bar' || chart_type === 'column' || chart_type === 'pie') { highchart_object.chart.type = chart_type; if (chart_type === 'bar' || chart_type === 'column') { highchart_object['xAxis'] = { categories: chart_data['titles'], }; } highchart_object['yAxis']['labels'] = {overflow: 'justify'}; } if (chart_type === 'pie') { highchart_object.series = [transform_to_pie(chart_data.series, 0, chart_data.categories)] highchart_object.plotOptions = { pie: { allowPointSelect: true, cursor: 'pointer', dataLabels: { enabled: true, format: '{point.percentage:.1f}% {point.name}', }, showInLegend: false, } }; highchart_object['legend'] = { layout: 'vertical', align: 'right', verticalAlign: 'top', x: -40, y: 100, floating: true, borderWidth: 1, shadow: true }; if (enable3d) { highchart_object.chart.options3d = { enabled: true, alpha: 45, beta: 0 }; highchart_object.plotOptions.pie.innerSize = 100; highchart_object.plotOptions.pie.depth = 45; } } else if (chart_type === 'column') { if (enable3d) { highchart_object.chart.options3d = { enabled: true, alpha: 10, beta: 25, depth: 50 }; highchart_object.plotOptions.column = { depth: 25 }; highchart_object.chart.margin = 70; } if (chartOptions['stacking']) { let stackingValue = chartOptions['stacking'] === true ? 'normal' : chartOptions['stacking']; highchart_object.plotOptions.series = {stacking: stackingValue}; } if (chartOptions['tooltip_formatter']) { highchart_object.tooltip = { useHTML: true, formatter: chartOptions['tooltip_formatter'], valueDecimals: 2 } } } else if (chart_type === 'area') { highchart_object.chart.type = 'area'; let areaStacking = chartOptions['stacking'] === true ? 'normal' : (chartOptions['stacking'] || undefined); highchart_object.plotOptions = { area: { stacking: areaStacking, marker: { enabled: false } } } } else if (chart_type === 'line') { let marker_enabled = true; highchart_object.chart.type = 'line'; // disable marker when ticks are more then 12 , relying on the hover of the mouse ; try { if (highchart_object.series[0].data.length > 12) marker_enabled = false; } catch (err) { } highchart_object.plotOptions = { line: { marker: { enabled: false } } }; highchart_object.xAxis.labels.enabled = marker_enabled; highchart_object.tooltip.useHTML = true; highchart_object.tooltip.shared = true; highchart_object.tooltip.crosshairs = true; } if (is_time_series) { if (chartOptions.plot_total && chartOptions.type !== 'line') { highchart_object.xAxis.categories = [chartOptions.title] } else { highchart_object.xAxis.categories = chart_data.titles; } highchart_object.xAxis.tickmarkPlacement = 'on'; if (chart_type !== 'line') highchart_object.tooltip.shared = false //Option here; } else { highchart_object.xAxis.categories = chart_data.titles } highchart_object.credits = $.slick_reporting.highcharts.defaults.credits; highchart_object.lang = { noData: $.slick_reporting.highcharts.defaults.messages.noData }; return highchart_object; } catch (err) { console.log(err); } } function get_normal_data(response, chartOptions) { let data_sources = {}; let series = [] chartOptions.data_source; let categories = [] chartOptions.data_source.forEach(function (elem, key) { data_sources[elem] = []; //{'name': chartOptions.series_name[key],} response.columns.forEach(function (col, key) { if (elem === col.name) { data_sources[elem].push(col.name) categories.push(col.verbose_name) } }) }) response.data.forEach(function (elem, index) { series.push({ 'name': elem[chartOptions.title_source], 'data': [elem[chartOptions.data_source]] }) }) return { 'categories': categories, 'titles': categories, 'series': series, } } function get_time_series_data(response, chartOptions) { let series = [] let data_sources = {}; chartOptions.data_source.forEach(function (elem, key) { data_sources[elem] = []; response.columns.forEach(function (col, key) { if (col.computation_field === elem) { data_sources[elem].push(col.name) } }) }) if (!chartOptions.plot_total) { response.data.forEach(function (elem, index) { Object.keys(data_sources).forEach(function (series_cols, index) { let data = [] data_sources[series_cols].forEach(function (col, index) { data.push(elem[col]) }) series.push({ 'name': elem[chartOptions.title_source], 'data': data }) }) }) } else { let all_column_to_be_summed = [] let data = [] Object.keys(data_sources).forEach(function (series_cols, index) { all_column_to_be_summed = all_column_to_be_summed.concat(data_sources[series_cols]); }) let totalValues = $.slick_reporting.calculateTotalOnObjectArray(response.data, all_column_to_be_summed) Object.keys(data_sources).forEach(function (series_cols, index) { data_sources[series_cols].forEach(function (col, index) { data.push(totalValues[col]) if (chartOptions.type !== "line") { series.push({ 'name': response.metadata.time_series_column_verbose_names[index], data: [totalValues[col]] }) } }) }) if (chartOptions.type === "line") { series.push({ 'name': chartOptions.title, data: data }) } } return { 'categories': response.metadata.time_series_column_verbose_names, 'titles': response.metadata.time_series_column_verbose_names, 'series': series, } } function get_crosstab_data(response, chartOptions) { let series = [] let data_sources = {}; let col_dict = dataArrayToObject(response.columns, 'name') chartOptions.data_source.forEach(function (elem, key) { data_sources[elem] = []; response.columns.forEach(function (col, key) { if (col.computation_field === elem) { data_sources[elem].push(col.name) } }) }) if (!chartOptions.plot_total) { response.data.forEach(function (elem, index) { Object.keys(data_sources).forEach(function (series_cols, index) { let data = [] data_sources[series_cols].forEach(function (col, index) { data.push(elem[col]) }) series.push({ 'name': elem[chartOptions.title_source], 'data': data }) }) }) } else { let all_column_to_be_summed = [] Object.keys(data_sources).forEach(function (series_cols, index) { all_column_to_be_summed = all_column_to_be_summed.concat(data_sources[series_cols]); }) let totalValues = $.slick_reporting.calculateTotalOnObjectArray(response.data, all_column_to_be_summed) Object.keys(data_sources).forEach(function (series_cols, index) { data_sources[series_cols].forEach(function (col, index) { series.push({ 'name': col_dict[col].verbose_name, 'data': [totalValues[col]] }) }) }) } return { 'categories': response.metadata.crosstab_column_verbose_names, 'titles': response.metadata.crosstab_column_verbose_names, 'series': series, } } function is_timeseries_support(response, chartOptions) { if (chartOptions.time_series_support === false) return false; return response.metadata.time_series_pattern || '' } function is_crosstab_support(response, chartOptions) { return response.metadata.crosstab_model || '' } function displayChart(data, $elem, chartOptions) { if ($elem.find("div[data-inner-chart-container]").length === 0) { $elem.append('
') } let chartContainer = $elem.find("div[data-inner-chart-container]")[0]; let cache_key = $.slick_reporting.get_xpath($elem) + ":" + data.report_slug; try { let existing_chart = _chart_cache[cache_key]; if (typeof (existing_chart) !== 'undefined') { existing_chart.destroy() } } catch (e) { console.error(e) } let chartObject = $.slick_reporting.highcharts.createChartObject(data, chartOptions); _chart_cache[cache_key] = Highcharts.chart(chartContainer, chartObject); } $.slick_reporting.highcharts = { createChartObject: createChartObject, displayChart: displayChart, defaults: { normalStackedTooltipFormatter: normalStackedTooltipFormatter, messages: { noData: 'No Data to display ... :-/', total: 'Total', percent: 'Percent', }, credits: { // text: '', // href: '' }, enable3d: false, } }; } (jQuery) ); ================================================ FILE: slick_reporting/static/slick_reporting/slick_reporting.js ================================================ (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 ${functionName} is not found in the context ${context}` } } catch (err) { console.error(`Function ${functionName} is not found in the context ${context}`, err) } return func.apply(context, args); } function getObjFromArray(objList, obj_key, key_value, failToFirst) { failToFirst = typeof (failToFirst) !== 'undefined'; if (key_value !== '') { for (let i = 0; i < objList.length; i++) { if (objList[i][obj_key] === key_value) { return objList[i]; } } } if (failToFirst && objList.length > 0) { return objList[0] } return false; } function calculateTotalOnObjectArray(data, columns) { // Compute totals in array of objects // example : // calculateTotalOnObjectArray ([{ value1:500, value2: 70} , {value: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 get_xpath($element, forceTree) { if ($element.length === 0) { return null; } let element = $element[0]; if ($element.attr('id') && ((forceTree === undefined) || !forceTree)) { return '//*[@id="' + $element.attr('id') + '"]'; } else { let paths = []; for (; element && element.nodeType === Node.ELEMENT_NODE; element = element.parentNode) { let index = 0; for (let sibling = element.previousSibling; sibling; sibling = sibling.previousSibling) { if (sibling.nodeType === Node.DOCUMENT_TYPE_NODE) continue; if (sibling.nodeName === element.nodeName) ++index; } var tagName = element.nodeName.toLowerCase(); var pathIndex = (index ? '[' + (index + 1) + ']' : ''); paths.splice(0, 0, tagName + pathIndex); } return paths.length ? '/' + paths.join('/') : null; } } $.slick_reporting = { 'getObjFromArray': getObjFromArray, 'calculateTotalOnObjectArray': calculateTotalOnObjectArray, "executeFunctionByName": executeFunctionByName, "get_xpath": get_xpath, defaults: { total_label: 'Total', } } $.slick_reporting.cache = {} }(jQuery)); ================================================ FILE: slick_reporting/static/slick_reporting/slick_reporting.report_loader.js ================================================ /*jshint esversion: 6 */ /** * Created by ramezashraf on 13/08/16. */ (function ($) { let settings = {}; function failFunction(data, $elem) { if (data.status === 403) { $elem.hide() } else { console.log(data, $elem) } } function loadComponents(data, $elem) { 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'); if (chartElem.length !== 0 && data.chart_settings.length !== 0) { $.slick_reporting.report_loader.displayChart(data, chartElem, chart_id); } if (display_chart_selector !== "False" && data.chart_settings.length > 1) { $.slick_reporting.report_loader.createChartsUIfromResponse(data, $elem); } let tableElem = $elem.find('[data-report-table]'); if (tableElem.length !== 0) { $.slick_reporting.datatable.buildAdnInitializeDatatable(data, tableElem); } } function displayChart(data, $elem, chart_id) { let engine = "highcharts"; let chartOptions = $.slick_reporting.getObjFromArray(data.chart_settings, 'id', chart_id, true); let entryPoint = chartOptions.entryPoint || $.slick_reporting.report_loader.chart_engines[engine]; try { $.slick_reporting.executeFunctionByName(entryPoint, window, data, $elem, chartOptions); } catch (e) { // Chart engine is not found or some error in the chart rendering, // we catch it and display an error message instead of breaking the entire report $("

Chart could not be loaded: " + e + "

").insertAfter($elem); $elem.remove(); console.error(e); } } function refreshReportWidget($elem, extra_params) { let successFunctionName = $elem.attr('data-success-callback'); successFunctionName = successFunctionName || "$.slick_reporting.report_loader.successCallback"; let failFunctionName = $elem.attr('data-fail-callback'); failFunctionName = failFunctionName || "$.slick_reporting.report_loader.failFunction"; let data = {}; let url = $elem.attr('data-report-url'); extra_params = extra_params || '' let extraParams = extra_params + ($elem.attr('data-extra-params') || ''); 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 if (extraParams !== '') { url = url + "?" + extraParams; } } $.get(url, data, function (data) { $.slick_reporting.cache[data['report_slug']] = jQuery.extend(true, {}, data); $.slick_reporting.executeFunctionByName(successFunctionName, window, data, $elem); }).fail(function (data) { $.slick_reporting.executeFunctionByName(failFunctionName, window, data, $elem); }); } function initialize() { settings = JSON.parse(document.getElementById('slick_reporting_settings').textContent); let chartSettings = {}; $('[data-report-widget]').not('[data-no-auto-load]').each(function (i, elem) { refreshReportWidget($(elem)); }); Object.keys(settings["CHARTS"]).forEach(function (key) { chartSettings[key] = settings.CHARTS[key].entryPoint; }) $.slick_reporting.report_loader.chart_engines = chartSettings; $.slick_reporting.defaults.total_label = settings["MESSAGES"]["TOTAL_LABEL"]; } function _get_chart_icon(chart_type) { try { return ""; } catch (e) { console.error(e); } return ''; } function createChartsUIfromResponse(data, $elem, a_class) { a_class = typeof a_class == 'undefined' ? 'groupChartController' : a_class; let $container = $('
'); let chartList = data['chart_settings']; let report_slug = data['report_slug']; $elem.find('.groupChartControllers').remove(); if (chartList.length !== 0) { $container.append('
' + '
'); } var ul = $container.find('ul'); for (var i = 0; i < chartList.length; i++) { var icon; var chart = chartList[i]; if (chart.disabled) continue; let chart_type = chart.type; icon = _get_chart_icon(chart_type); ul.append('
') } $elem.prepend($container) return $container } jQuery(document).ready(function () { $.slick_reporting.report_loader.initialize(); $('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) }); $('[data-export-btn]').on('click', function (e) { let $elem = $(this); e.preventDefault(); let form = $($elem.attr('data-form-selector')); let url = '?' + form.serialize() + '&_export=' + $elem.attr('data-export-parameter'); if ($elem.attr('data-export-new-window')) { window.open(url, '_blank'); } else { window.location = url; } }); $('[data-get-results-button]').not(".vanilla-btn-flag").on('click', function (event) { event.preventDefault(); let $elem = $('[data-report-widget]') $.slick_reporting.report_loader.refreshReportWidget($elem) }); }); $.slick_reporting.report_loader = { cache: $.slick_reporting.cache, // "extractDataFromResponse": extractDataFromResponse, initialize: initialize, refreshReportWidget: refreshReportWidget, failFunction: failFunction, displayChart: displayChart, createChartsUIfromResponse: createChartsUIfromResponse, successCallback: loadComponents, } })(jQuery); ================================================ FILE: slick_reporting/templates/slick_reporting/base.html ================================================ {% load static %} {% load crispy_forms_tags %} {% block extra_head %} {% endblock %} {{ report_title }} | Django Slick Reporting

{{ report_title }}

{% block content %} {% endblock %}
{% block extrajs %} {% include "slick_reporting/js_resources.html" %} {% endblock %} ================================================ FILE: slick_reporting/templates/slick_reporting/js_resources.html ================================================ {% load i18n static slick_reporting_tags %} {% get_slick_reporting_settings as slick_reporting_settings %} {% add_jquery %} {% get_slick_reporting_media as media %} {{ media }} {{ slick_reporting_settings|json_script:"slick_reporting_settings" }} ================================================ FILE: slick_reporting/templates/slick_reporting/print_report.html ================================================ {% load i18n %} {% get_current_language as LANGUAGE_CODE %} {% get_current_language_bidi as LANGUAGE_BIDI %} {{ report_title }} {% include "slick_reporting/print_report_controls.html" %} {% include "slick_reporting/print_report_header.html" %}

{{ report_title }}

{% for header in headers %}{% endfor %} {% for row in rows %} {% for cell in row %}{% endfor %} {% endfor %}
{{ header }}
{{ cell }}
{% include "slick_reporting/print_report_footer.html" %} ================================================ FILE: slick_reporting/templates/slick_reporting/print_report_controls.html ================================================ {% load i18n %} {# Override this template to customise the on-screen controls shown above the printed page. #}
================================================ FILE: slick_reporting/templates/slick_reporting/print_report_footer.html ================================================ {# Override this template in your project to add a custom print footer (e.g. signatures, notes, page numbers). #} {# Context available: report_title, headers, rows #} ================================================ FILE: slick_reporting/templates/slick_reporting/print_report_header.html ================================================ {# Override this template in your project to add a custom print header (e.g. company name, logo, date range). #} {# Context available: report_title, headers, rows #} ================================================ FILE: slick_reporting/templates/slick_reporting/report.html ================================================ {% extends 'slick_reporting/base.html' %} {% load crispy_forms_tags i18n slick_reporting_tags %} {% block content %}
{% if form %} {% include "slick_reporting/report_form.html" %} {% endif %}
{% translate "Results" %}
{% endblock %} {% block extrajs %} {{ block.super }} {% get_charts_media report.get_chart_settings %} {% endblock %} ================================================ FILE: slick_reporting/templates/slick_reporting/report_form.html ================================================ {% load i18n crispy_forms_tags %}

{% translate "Filters" %}

{% if form and crispy_helper %} {% crispy form crispy_helper %} {% else %} {% crispy form %} {% endif %}
================================================ FILE: slick_reporting/templates/slick_reporting/widget_template.html ================================================ {% load slick_reporting_tags %}
{% if display_title %}
{{ title }}
{% endif %}
{% block widget_content %} {% if display_chart %}
{% endif %} {% if display_table %}
{% endif %} {% endblock %}
================================================ FILE: slick_reporting/templatetags/__init__.py ================================================ ================================================ FILE: slick_reporting/templatetags/slick_reporting_tags.py ================================================ from django import template from django.template.loader import get_template from django.forms import Media from django.templatetags.static import static from django.urls import reverse, resolve from django.utils.safestring import mark_safe from ..app_settings import SLICK_REPORTING_JQUERY_URL, SLICK_REPORTING_SETTINGS, get_media register = template.Library() def _resolve_static(path): """Return an absolute URL for a static asset path, mirroring Media.absolute_path().""" if path.startswith(("http://", "https://", "/")): return path return static(path) @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) @register.simple_tag def add_jquery(): if SLICK_REPORTING_JQUERY_URL: url = _resolve_static(SLICK_REPORTING_JQUERY_URL) return mark_safe(f'') return "" @register.simple_tag def get_charts_media(chart_settings): charts_dict = SLICK_REPORTING_SETTINGS["CHARTS"] media = Media() if chart_settings == "all": available_types = charts_dict.keys() else: available_types = [chart["engine_name"] for chart in chart_settings] available_types = set(available_types) for type in available_types: media += Media(css=charts_dict.get(type, {}).get("css", {}), js=charts_dict.get(type, {}).get("js", [])) return media @register.simple_tag def get_slick_reporting_media(): from django.forms import Media media = get_media() return Media(css=media["css"], js=media["js"]) @register.simple_tag def get_slick_reporting_settings(): settings = dict(SLICK_REPORTING_SETTINGS) media = dict(settings.get("MEDIA", {})) media["js"] = [_resolve_static(p) for p in media.get("js", [])] settings["MEDIA"] = media charts = {} for engine, config in settings.get("CHARTS", {}).items(): config = dict(config) config["js"] = [_resolve_static(p) for p in config.get("js", [])] charts[engine] = config settings["CHARTS"] = charts font_awesome = dict(settings.get("FONT_AWESOME", {})) if "CSS_URL" in font_awesome: font_awesome["CSS_URL"] = _resolve_static(font_awesome["CSS_URL"]) settings["FONT_AWESOME"] = font_awesome return settings ================================================ FILE: slick_reporting/views.py ================================================ import csv import datetime import warnings import simplejson as json from django import forms from django.conf import settings from django.contrib.auth.mixins import UserPassesTestMixin from django.db.models import Q from django.forms import modelform_factory from django.http import HttpResponse, StreamingHttpResponse, JsonResponse from django.utils.encoding import force_str from django.utils.functional import Promise from django.views.generic import FormView from .app_settings import SLICK_REPORTING_SETTINGS, get_access_function from .forms import ( report_form_factory, get_crispy_helper, default_formfield_callback, OrderByForm, ) from .generator import ( ReportGenerator, ListViewReportGenerator, ReportGeneratorAPI, Chart, # noqa # needed for easier importing in other apps ) def dictsort(value, arg, desc=False): """ Takes a list of dicts, returns that list sorted by the property given in the argument. """ return sorted(value, key=lambda x: x[arg], reverse=desc) class ExportToCSV(object): def get_filename(self): return self.report_title def get_response(self): response = HttpResponse(content_type="text/csv") response["Content-Disposition"] = "attachment; filename={filename}.csv".format(filename=self.get_filename()) writer = csv.writer(response) for rows in self.get_rows(): writer.writerow(rows) return response def get_rows(self): columns, verbose_names = self.get_columns() yield verbose_names for line in self.report_data["data"]: 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"]])) def __init__(self, request, report_data, report_title, **kwargs): self.request = request self.report_data = report_data self.report_title = report_title self.kwargs = kwargs class ExportToStreamingCSV(ExportToCSV): def get_response(self): # Copied form Djagno Docs class Echo: def write(self, value): return value pseudo_buffer = Echo() writer = csv.writer(pseudo_buffer) return StreamingHttpResponse( (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()) }, ) class PrintHTMLExport: template_name = "slick_reporting/print_report.html" def __init__(self, request, report_data, report_title, **kwargs): self.request = request self.report_data = report_data self.report_title = report_title def get_response(self): from django.shortcuts import render columns = self.report_data.get("columns", []) headers = [col["verbose_name"] for col in columns] rows = [[row.get(col["name"], "") for col in columns] for row in self.report_data.get("data", [])] return render(self.request, self.template_name, {"report_title": self.report_title, "headers": headers, "rows": rows}) class ReportViewBase(ReportGeneratorAPI, UserPassesTestMixin, FormView): report_slug = None report_title = "" report_description = "" report_title_context_key = "report_title" report_generator_class = ReportGenerator base_model = None chart_settings = None excluded_fields = None time_series_selector = False time_series_selector_choices = None time_series_selector_default = None time_series_selector_allow_empty = False csv_export_class = ExportToStreamingCSV print_export_class = PrintHTMLExport with_type = False doc_type_field_name = "doc_type" doc_type_plus_list = None doc_type_minus_list = None auto_load = True chart_engine = "" default_order_by = "" template_name = "slick_reporting/report.html" export_actions = None def test_func(self): access_function = get_access_function() return access_function(self) @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) if order_field: data = dictsort(data, order_field, asc) return 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_minus_list})] if self.doc_type_minus_list else [] ) return [], [] def get_export_actions(self): """ Hook to get the export options :return: list of export options """ actions = [] if self.csv_export_class: actions.append("export_csv") if self.print_export_class: actions.append("export_print") if self.export_actions: actions = actions + self.export_actions export_actions = [] for action in actions: func = getattr(self, action, None) parameter = action.replace("export_", "") export_actions.append( { "name": action, "title": getattr(func, "title", action.replace("_", " ").title()), "icon": getattr(func, "icon", ""), "css_class": getattr(func, "css_class", ""), "parameter": parameter, "new_window": getattr(func, "new_window", False), } ) return export_actions 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(): 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() export_csv.title = SLICK_REPORTING_SETTINGS["MESSAGES"]["export_to_csv"] export_csv.css_class = "btn btn-primary" export_csv.icon = "" def export_print(self, report_data): return self.print_export_class(self.request, report_data, self.get_report_title()).get_response() export_print.title = SLICK_REPORTING_SETTINGS["MESSAGES"]["print_report"] export_print.css_class = "btn btn-secondary" export_print.icon = "" export_print.new_window = True @classmethod def get_report_model(cls): if cls.queryset is not None: return cls.queryset.model if not cls.report_model and cls.table_name: from .dynamic_model import get_dynamic_model return get_dynamic_model(cls.table_name) return cls.report_model def ajax_render_to_response(self, report_data): 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.""" def date_handler(obj): if type(obj) is datetime.datetime: return obj.strftime("%Y-%m-%d %H:%M") elif hasattr(obj, "isoformat"): return obj.isoformat() elif isinstance(obj, Promise): return force_str(obj) indent = None if settings.DEBUG: indent = 4 return json.dumps(response_data, indent=indent, use_decimal=True, default=date_handler) def get_form_class(self): """ Automatically instantiate a form based on details provided :return: """ return self.form_class or report_form_factory( self.get_report_model(), crosstab_model=self.crosstab_field, display_compute_remainder=self.crosstab_compute_remainder, excluded_fields=self.excluded_fields, fkeys_filter_func=self.fkeys_filter_func_hook, initial=self.get_initial(), show_time_series_selector=self.time_series_selector, time_series_selector_choices=self.time_series_selector_choices, time_series_selector_default=self.time_series_selector_default, time_series_selector_allow_empty=self.time_series_selector_allow_empty, add_start_date=self.start_date_field_name or self.date_field, add_end_date=self.end_date_field_name or self.date_field, ) @staticmethod def fkeys_filter_func_hook(fkeys_dict): """ A hook to customize which fileds to eliminate on the form Example Useage: ``` exclude_list = ["owner_id", "polymorphic_ctype_id", "lastmod_user_id"] return {K:v for k,v in fkeys_dict.items() if k not in exclude_list} ``` """ return fkeys_dict def get_form_kwargs(self): """ Returns the keyword arguments for instantiating the form. """ kwargs = { "initial": self.get_initial(), "prefix": self.get_prefix(), } if self.request.method in ("POST", "PUT"): kwargs.update( { "data": self.request.POST, "files": self.request.FILES, } ) elif self.request.method in ("GET", "PUT"): if self.request.GET or self.request.headers.get("x-requested-with") == "XMLHttpRequest": kwargs.update( { "data": self.request.GET, } ) return kwargs def get_crosstab_ids(self): """ Hook to get the crosstab ids :return: """ return self.form.get_crosstab_ids() def get_group_by_custom_querysets(self): return self.group_by_custom_querysets def get_report_generator(self, queryset=None, for_print=False): queryset = queryset or self.get_queryset() q_filters, kw_filters = self.form.get_filters() crosstab_compute_remainder = False if self.crosstab_field: self.crosstab_ids = self.get_crosstab_ids() try: crosstab_compute_remainder = ( self.form.get_crosstab_compute_remainder() if self.crosstab_compute_remainder is None and (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: time_series_pattern = self.form.get_time_series_pattern() doc_type_plus_list, doc_type_minus_list = [], [] if self.with_type: doc_type_plus_list, doc_type_minus_list = self.get_doc_types_q_filters() 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, main_queryset=queryset, print_flag=for_print, limit_records=self.limit_records, swap_sign=self.swap_sign, columns=self.columns, group_by=self.group_by, group_by_custom_querysets=self.get_group_by_custom_querysets(), group_by_custom_querysets_column_verbose_name=self.group_by_custom_querysets_column_verbose_name, time_series_pattern=time_series_pattern, time_series_columns=self.time_series_columns, time_series_custom_dates=self.time_series_custom_dates, crosstab_field=self.crosstab_field, 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, crosstab_precomputed=self.crosstab_precomputed, format_row_func=self.format_row, container_class=self, doc_type_plus_list=doc_type_plus_list, doc_type_minus_list=doc_type_minus_list, ) def format_row(self, row_obj): """ A hook to format each row . This method gets called on each row in the results. None: # Skip early validation for table_name-based (dynamic) views: the model # requires live DB introspection which must not happen at import time # (e.g. during `manage.py migrate`). Validation runs at request time via # ReportGenerator._parse(). if cls.columns and not cls.table_name: cls.report_generator_class.check_columns( cls, cls.columns, cls.group_by, cls.get_report_model(), container_class=cls, group_by_custom_querysets=cls.group_by_custom_querysets, ) super().__init_subclass__() class SlickReportingListViewMixin(ReportViewBase): report_generator_class = ListViewReportGenerator filters = None def get_queryset(self): qs = self.queryset or self.report_model.objects if self.default_order_by: qs.order_by(self.default_order_by) return qs def get_form_filters(self, form): if hasattr(form, "get_filters"): return form.get_filters() kw_filters = {} for name, field in form.base_fields.items(): if type(field) is forms.ModelMultipleChoiceField: value = form.cleaned_data[name] if value: kw_filters[f"{name}__in"] = form.cleaned_data[name] elif type(field) is forms.BooleanField: # boolean field while checked on frontend , and have initial = True, give false value on cleaned_data # Hence this check to see if it was indeed in the GET params, value = field.initial if self.request.GET: value = form.cleaned_data.get(name, False) kw_filters[name] = value else: value = form.cleaned_data[name] if value: kw_filters[name] = form.cleaned_data[name] return [], kw_filters def get_form_crispy_helper(self): return get_crispy_helper(self.filters) def get_report_generator(self, queryset=None, for_print=False): q_filters, kw_filters = self.get_form_filters(self.form) return self.report_generator_class( self.get_report_model(), # start_date=self.form.get_start_date(), # end_date=self.form.get_end_date(), q_filters=q_filters, kwargs_filters=kw_filters, date_field=self.date_field, main_queryset=queryset, print_flag=for_print, limit_records=self.limit_records, columns=self.columns, format_row_func=self.format_row, container_class=self, ) def get_form_class(self): if self.form_class: return self.form_class elif self.filters: return modelform_factory( model=self.get_report_model(), fields=self.filters, formfield_callback=default_formfield_callback, ) return report_form_factory( self.get_report_model(), crosstab_model=self.crosstab_field, display_compute_remainder=self.crosstab_compute_remainder, excluded_fields=self.excluded_fields, fkeys_filter_func=self.fkeys_filter_func_hook, initial=self.get_initial(), show_time_series_selector=self.time_series_selector, time_series_selector_choices=self.time_series_selector_choices, time_series_selector_default=self.time_series_selector_default, time_series_selector_allow_empty=self.time_series_selector_allow_empty, add_start_date=self.start_date_field_name or self.date_field, add_end_date=self.end_date_field_name or self.date_field, ) def get_report_results(self, for_print=False): """ Gets the reports Data, and, its meta data used by datatables.net and highcharts :return: JsonResponse """ queryset = self.get_queryset() report_generator = self.get_report_generator(queryset, for_print) data = report_generator.get_report_data() data = self.filter_results(data, for_print) return report_generator.get_full_response( data=data, report_slug=self.get_report_slug(), chart_settings=self.chart_settings, default_chart_title=self.report_title, ) class SlickReportingListView(SlickReportingListViewMixin, ReportViewBase): def __init_subclass__(cls) -> None: warnings.warn( "slick_reporting.view.SlickReportingListView is" "deprecated in favor of slick_reporting.view.ListReportView", Warning, stacklevel=2, ) super().__init_subclass__() class ListReportView(SlickReportingListViewMixin): pass class SlickReportViewBase(ReportViewBase): """ Deprecated in favor of slick_reporting.view.ReportViewBase """ def __init_subclass__(cls) -> None: warnings.warn( "slick_reporting.view.SlickReportView and slick_reporting.view.SlickReportViewBase are " "deprecated in favor of slick_reporting.view.ReportView and slick_reporting.view.BaseReportView", Warning, stacklevel=2, ) super().__init_subclass__() class SlickReportView(ReportView): def __init_subclass__(cls) -> None: warnings.warn( "slick_reporting.view.SlickReportView and slick_reporting.view.SlickReportViewBase are " "deprecated in favor of slick_reporting.view.ReportView and slick_reporting.view.BaseReportView", Warning, stacklevel=2, ) # cls.report_generator_class.check_columns( # cls.columns, cls.group_by, cls.get_report_model(), container_class=cls # ) ================================================ FILE: tests/__init__.py ================================================ ================================================ FILE: tests/models.py ================================================ from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.db import models from django.utils.translation import gettext_lazy as _ class Product(models.Model): CATEGORY_CHOICES = ( ("tiny", "tiny"), ("small", "small"), ("medium", "medium"), ("big", "big"), ) slug = models.CharField(max_length=200, verbose_name=_("Slug")) name = models.CharField(max_length=200, verbose_name=_("Name")) sku = models.CharField(max_length=200, default="", blank=True) category = models.CharField(max_length=10, choices=CATEGORY_CHOICES) notes = models.TextField() class Meta: verbose_name = _("Product") verbose_name_plural = _("Products") class ProductCustomID(models.Model): CATEGORY_CHOICES = ( ("tiny", "tiny"), ("small", "small"), ("medium", "medium"), ("big", "big"), ) hash = models.AutoField(primary_key=True) slug = models.CharField(max_length=200, verbose_name=_("Slug")) name = models.CharField(max_length=200, verbose_name=_("Name")) sku = models.CharField(max_length=200, default="", blank=True) category = models.CharField(max_length=10, choices=CATEGORY_CHOICES) notes = models.TextField() class Meta: verbose_name = _("Product") verbose_name_plural = _("Products") class Agent(models.Model): name = models.CharField(max_length=200, verbose_name=_("Name")) class Contact(models.Model): address = models.CharField(max_length=200, verbose_name=_("Name")) po_box = models.CharField( max_length=200, verbose_name=_("po_box"), null=True, blank=True ) agent = models.ForeignKey(Agent, on_delete=models.CASCADE) class Client(models.Model): class SexChoices(models.TextChoices): FEMALE = "FEMALE", _("Female") MALE = "MALE", _("Male") OTHER = "OTHER", _("Other") slug = models.CharField(max_length=200, verbose_name=_("Client Slug")) name = models.CharField(max_length=200, verbose_name=_("Name"), unique=True) email = models.EmailField(blank=True) notes = models.TextField() contact = models.ForeignKey(Contact, on_delete=models.CASCADE, null=True) sex = models.CharField(max_length=10, choices=SexChoices.choices, default="OTHER") class Meta: verbose_name = _("Client") verbose_name_plural = _("Clients") class SimpleSales(models.Model): slug = models.SlugField() doc_date = models.DateTimeField(_("date"), db_index=True) client = models.ForeignKey(Client, on_delete=models.CASCADE) product = models.ForeignKey(Product, on_delete=models.CASCADE) quantity = models.DecimalField( _("quantity"), max_digits=19, decimal_places=2, default=0 ) price = models.DecimalField(_("price"), max_digits=19, decimal_places=2, default=0) value = models.DecimalField(_("value"), max_digits=19, decimal_places=2, default=0) created_at = models.DateTimeField(null=True, verbose_name=_("Created at")) flag = models.CharField(max_length=50, default="sales") content_type = models.ForeignKey( ContentType, on_delete=models.DO_NOTHING, null=True ) object_id = models.PositiveIntegerField(null=True) content_object = GenericForeignKey("content_type", "object_id") def save( self, *args, **kwargs ): self.value = self.quantity * self.price super().save(*args, **kwargs) class Meta: verbose_name = _("Sale") verbose_name_plural = _("Sales") ordering = ["-created_at"] class SimpleSales2(models.Model): slug = models.SlugField() doc_date = models.DateTimeField(_("date"), db_index=True) client = models.ForeignKey(Client, on_delete=models.CASCADE, to_field="name") product = models.ForeignKey(Product, on_delete=models.CASCADE) quantity = models.DecimalField( _("quantity"), max_digits=19, decimal_places=2, default=0 ) price = models.DecimalField(_("price"), max_digits=19, decimal_places=2, default=0) value = models.DecimalField(_("value"), max_digits=19, decimal_places=2, default=0) created_at = models.DateTimeField(null=True, verbose_name=_("Created at")) flag = models.CharField(max_length=50, default="sales") content_type = models.ForeignKey( ContentType, on_delete=models.DO_NOTHING, null=True ) object_id = models.PositiveIntegerField(null=True) content_object = GenericForeignKey("content_type", "object_id") def save( self, *args, **kwargs ): self.value = self.quantity * self.price super().save(*args, **kwargs) class Meta: verbose_name = _("Sale") verbose_name_plural = _("Sales") ordering = ["-created_at"] class SalesProductWithCustomID(models.Model): slug = models.SlugField() doc_date = models.DateTimeField(_("date"), db_index=True) client = models.ForeignKey(Client, on_delete=models.CASCADE) product = models.ForeignKey(ProductCustomID, on_delete=models.CASCADE) quantity = models.DecimalField( _("quantity"), max_digits=19, decimal_places=2, default=0 ) price = models.DecimalField(_("price"), max_digits=19, decimal_places=2, default=0) value = models.DecimalField(_("value"), max_digits=19, decimal_places=2, default=0) created_at = models.DateTimeField(null=True, verbose_name=_("Created at")) flag = models.CharField(max_length=50, default="sales") content_type = models.ForeignKey( ContentType, on_delete=models.DO_NOTHING, null=True ) object_id = models.PositiveIntegerField(null=True) content_object = GenericForeignKey("content_type", "object_id") def save( self, *args, **kwargs ): self.value = self.quantity * self.price super().save(*args, **kwargs) class Meta: verbose_name = _("Sale") verbose_name_plural = _("Sales") ordering = ["-created_at"] class SalesWithFlag(models.Model): slug = models.SlugField() doc_date = models.DateTimeField(_("date"), db_index=True) client = models.ForeignKey(Client, on_delete=models.CASCADE) product = models.ForeignKey(Product, on_delete=models.CASCADE) quantity = models.DecimalField( _("quantity"), max_digits=19, decimal_places=2, default=0 ) price = models.DecimalField(_("price"), max_digits=19, decimal_places=2, default=0) value = models.DecimalField(_("value"), max_digits=19, decimal_places=2, default=0) created_at = models.DateTimeField(null=True, verbose_name=_("Created at")) flag = models.CharField(max_length=50, default="sales") def save( self, *args, **kwargs ): self.value = self.quantity * self.price super().save(*args, **kwargs) class Meta: verbose_name = _("Sale") verbose_name_plural = _("Sales") ordering = ["-created_at"] class UserJoined(models.Model): username = models.CharField(max_length=255) date_joined = models.DateField() class TaxCode(models.Model): name = models.CharField(max_length=255) tax = models.DecimalField(_("tax"), max_digits=19, decimal_places=2, default=0) class ComplexSales(models.Model): tax = models.ManyToManyField(TaxCode) slug = models.SlugField() doc_date = models.DateTimeField(_("date"), db_index=True) client = models.ForeignKey(Client, on_delete=models.CASCADE) product = models.ForeignKey(Product, on_delete=models.CASCADE) quantity = models.DecimalField( _("quantity"), max_digits=19, decimal_places=2, default=0 ) price = models.DecimalField(_("price"), max_digits=19, decimal_places=2, default=0) value = models.DecimalField(_("value"), max_digits=19, decimal_places=2, default=0) created_at = models.DateTimeField(null=True, verbose_name=_("Created at")) flag = models.CharField(max_length=50, default="sales") content_type = models.ForeignKey( ContentType, on_delete=models.DO_NOTHING, null=True ) object_id = models.PositiveIntegerField(null=True) content_object = GenericForeignKey("content_type", "object_id") def save( self, *args, **kwargs ): self.value = self.quantity * self.price super().save(*args, **kwargs) class Meta: verbose_name = _("VAT Sale") verbose_name_plural = _("VAT Sales") ordering = ["-created_at"] # # class Invoice(BaseMovementInfo): # client = models.ForeignKey(Client, on_delete=models.CASCADE) # # @classmethod # def get_doc_type(cls): # return 'sales' # # # class InvoiceLine(QuanValueMovementItem): # invoice = models.ForeignKey(Invoice, on_delete=models.CASCADE) # # product = models.ForeignKey(Product, on_delete=models.CASCADE) # client = models.ForeignKey(Client, on_delete=models.CASCADE) # # @classmethod # def get_doc_type(cls): # return 'sales' # # # class Journal(BaseMovementInfo): # data = models.CharField(max_length=100, null=True, blank=True) # # @classmethod # def get_doc_type(cls): # return 'journal-sales' # # # class JournalItem(BaseMovementItemInfo): # journal = models.ForeignKey(Journal, on_delete=models.CASCADE) # client = models.ForeignKey(Client, on_delete=models.CASCADE) # data = models.CharField(max_length=100, null=True, blank=True) # # @classmethod # def get_doc_type(cls): # return 'journal-sales' # # # class JournalWithCriteria(Journal): # class Meta: # proxy = True # # Vanilla models class Order(models.Model): date_placed = models.DateTimeField(auto_created=True) client = models.ForeignKey(Client, null=True, on_delete=models.CASCADE) class OrderLine(models.Model): date_placed = models.DateTimeField(auto_created=True) product = models.ForeignKey(Product, on_delete=models.CASCADE) order = models.ForeignKey(Order, on_delete=models.CASCADE) quantity = models.PositiveIntegerField(default=0) client = models.ForeignKey(Client, null=True, on_delete=models.CASCADE) class Architect(models.Model): """A lookup table for CX Enterprise Architects, used mostly for reporting purposes. Associated with Initiatives and Features.""" id = models.AutoField(primary_key=True) name = models.CharField( max_length=60, verbose_name="Lead Architect", null=False, unique=True, blank=False, db_index=True, ) class Initiative(models.Model): id = models.AutoField(primary_key=True) # cx_pem = models.ForeignKey(ProjectEngineeringManager, on_delete=models.DO_NOTHING, # verbose_name="CX PEM:", null=True, # blank=True) architect = models.ForeignKey( Architect, on_delete=models.DO_NOTHING, verbose_name="CX Architect:", null=True, blank=True, to_field="name", ) ================================================ FILE: tests/report_generators.py ================================================ from __future__ import annotations import datetime from django.db.models import Sum, Count from django.utils.translation import gettext_lazy as _ from slick_reporting.fields import ComputationField, PercentageToTotalBalance from slick_reporting.generator import ReportGenerator from .models import ( Client, SimpleSales, Product, SalesWithFlag, SalesProductWithCustomID, ComplexSales, SimpleSales2, ) from .models import OrderLine class GenericGenerator(ReportGenerator): report_model = OrderLine date_field = "order__date_placed" # here is the meat and potatos of the report, # we group the sales per client , we display columns slug and title (of the `base_model` defied above # and we add the magic field `__balance__` we compute the client balance. group_by = "client" columns = ["slug", "name"] class GeneratorWithAttrAsColumn(GenericGenerator): group_by = "client" columns = ["get_data", "slug", "name"] def get_data(self, obj): return obj["name"] get_data.verbose_name = "My Verbose Name" class CrosstabOnClient(GenericGenerator): group_by = "product" columns = ["name", "__total_quantity__"] crosstab_field = "client" # crosstab_columns = ['__total_quantity__'] crosstab_columns = [ComputationField.create(Sum, "quantity", name="value__sum", verbose_name=_("Sales"))] class CrosstabTimeSeries(GenericGenerator): group_by = "product" columns = ["name", "__total_quantity__"] # crosstab_field = "client" # crosstab_columns = [ # ComputationField.create( # Sum, "quantity", name="value__sum", verbose_name=_("Sales") # ) # ] # crosstab_compute_remainder = False # time_series_pattern = "monthly" # time_series_columns = ["__total_quantity__"] class CrosstabOnField(ReportGenerator): report_model = ComplexSales date_field = "doc_date" group_by = "product" columns = ["name"] crosstab_field = "flag" crosstab_ids = ["sales", "sales-return"] crosstab_columns = [ComputationField.create(Sum, "quantity", name="value__sum", verbose_name=_("Sales"))] class CrosstabCustomQueryset(ReportGenerator): report_model = ComplexSales date_field = "doc_date" group_by = "product" columns = ["name"] crosstab_field = "flag" # crosstab_ids = ["sales", "sales-return"] crosstab_ids_custom_filters = [ (None, dict(flag="sales")), (None, dict(flag="sales-return")), ] crosstab_columns = [ComputationField.create(Sum, "quantity", name="value__sum", verbose_name=_("Sales"))] class CrosstabOnTraversingField(ReportGenerator): report_model = ComplexSales date_field = "doc_date" group_by = "product" columns = ["name"] crosstab_field = "client__sex" crosstab_ids = ["FEMALE", "MALE", "OTHER"] crosstab_columns = [ComputationField.create(Sum, "quantity", name="value__sum", verbose_name=_("Sales"))] class ClientTotalBalance(ReportGenerator): report_model = SimpleSales # date_field = "doc_date" group_by = "client" columns = [ "slug", "name", "__balance__", ComputationField.create(Sum, "value", name="__total__", verbose_name=_("Sales")), ] class TotalBalanceWithQueryset(ReportGenerator): report_model = SimpleSales queryset = SimpleSales.objects.filter(product_id=0) date_field = "doc_date" group_by = "client" columns = ["slug", "name", "__balance__", "__total__"] class ClientTotalBalance2(ReportGenerator): report_model = SimpleSales2 date_field = "doc_date" group_by = "client" columns = ["slug", "name", "__balance__", "__total__"] class GroupByCharField(ReportGenerator): report_model = SalesWithFlag date_field = "doc_date" group_by = "flag" columns = ["flag", "__balance__", ComputationField.create(Sum, "quantity")] class GroupByCharFieldPlusTimeSeries(ReportGenerator): report_model = SalesWithFlag date_field = "doc_date" group_by = "flag" columns = ["flag", ComputationField.create(Sum, "quantity")] time_series_pattern = "monthly" time_series_columns = [ComputationField.create(Sum, "quantity")] class ClientTotalBalancesOrdered(ClientTotalBalance): report_slug = None default_order_by = "__balance__" class ClientTotalBalancesOrderedDESC(ClientTotalBalance): report_slug = None default_order_by = "-__balance__" class ProductTotalSales(ReportGenerator): report_model = SimpleSales date_field = "doc_date" group_by = "product" columns = [ "slug", "name", "__balance__", "__balance_quantity__", "get_object_sku", "average_value", ] def get_object_sku(self, obj: dict, row: dict) -> any: """ :param obj: obj is the current row of the grouped by model , or the current row of the queryset :param row: the current report row values in a dictionary :return: """ return obj["sku"].upper() get_object_sku.verbose_name = "SKU ALL CAPS" def average_value(self, obj, data): return data["__balance__"] / data["__balance_quantity__"] average_value.verbose_name = "Average Value" class ProductTotalSalesProductWithCustomID(ReportGenerator): report_model = SalesProductWithCustomID date_field = "doc_date" group_by = "product" columns = ["slug", "name", "__balance__", "__balance_quantity__"] class ProductTotalSalesWithPercentage(ReportGenerator): report_model = SimpleSales date_field = "doc_date" group_by = "client" columns = [ "slug", "name", "__balance__", "__balance_quantity__", PercentageToTotalBalance, ] class ClientList(ReportGenerator): report_model = SimpleSales group_by = "client" columns = ["slug", "name"] class ProductClientSales(ReportGenerator): report_model = SimpleSales report_slug = "client_sales_of_products" report_title = _("Client net sales for each product") must_exist_filter = "client_id" header_report = ClientList group_by = "product" columns = ["slug", "name", "__balance_quantity__", "__balance__", "get_data"] def get_data(self, obj): return "" class ProductSalesMonthlySeries(ReportGenerator): base_model = Product report_model = SimpleSales report_title = _("Product Sales Monthly") group_by = "product" columns = ["slug", "name"] time_series_pattern = ("monthly",) time_series_columns = ["__balance_quantity__", "__balance__"] chart_settings = [ { "id": "movement_column", "name": _("comparison - column"), "settings": { "chart_type": "column", "name": _("{product} Avg. purchase price "), "sub_title": _("{date_verbose}"), "y_sources": ["__balance__"], "series_names": [_("Avg. purchase price")], }, }, { "id": "movement_line", "name": _("comparison - line"), "settings": { "chart_type": "line", "name": _("{product} Avg. purchase price "), "sub_title": _("{date_verbose}"), "y_sources": ["__balance__"], "series_names": [_("Avg. purchase price")], }, }, ] class TimeSeriesCustomDates(ReportGenerator): report_model = SimpleSales report_title = _("Product Sales Monthly") date_field = "doc_date" # group_by = 'product' # columns = ['slug', 'name'] time_series_pattern = "custom" time_series_columns = ["__total__"] time_series_custom_dates = [ (datetime.date(2020, 1, 1), datetime.date(2020, 1, 17)), (datetime.date(2020, 4, 17), datetime.date(2020, 5, 1)), (datetime.date(2020, 8, 8), datetime.date(2020, 9, 9)), ] class TimeSeriesWithOutGroupBy(ReportGenerator): report_model = SimpleSales report_title = _("Product Sales Monthly") date_field = "doc_date" # group_by = 'product' # columns = ['slug', 'name'] time_series_pattern = "monthly" time_series_columns = ["__total__"] class ClientReportMixin: base_model = Client report_model = SimpleSales class ClientSalesMonthlySeries(ReportGenerator): report_model = SimpleSales date_field = "doc_date" group_by = "client" columns = ["slug", "name"] time_series_pattern = "monthly" time_series_columns = ["__debit__", "__credit__", "__balance__", "__total__"] class CountField(ComputationField): calculation_field = "id" calculation_method = Count verbose_name = _("Count") name = "count__id" class TestCountField(ReportGenerator): report_model = ComplexSales group_by = "product" columns = ["slug", "name", CountField] date_field = "doc_date" # class ClientDetailedStatement(ReportGenerator): report_model = SimpleSales date_field = "doc_date" group_by = None columns = ["slug", "doc_date", "product__name", "quantity", "price", "value"] class ClientDetailedStatement2(ReportGenerator): report_title = _("client statement") base_model = Client report_model = SimpleSales header_report = ClientList must_exist_filter = "client_id" form_settings = { "group_by": "", "group_columns": [ "slug", "doc_date", "doc_type", "product__title", "quantity", "price", "value", ], } group_by = None columns = [ "slug", "doc_date", "doc_type", "product__title", "quantity", "price", "value", ] class ProductClientSalesMatrix(ReportGenerator): report_model = SimpleSales date_field = "doc_date" group_by = "product" columns = ["slug", "name"] crosstab_field = "client" crosstab_columns = ["__total__"] class ProductClientSalesMatrixToFieldSet(ReportGenerator): report_model = SimpleSales2 date_field = "doc_date" group_by = "product" columns = ["slug", "name"] crosstab_field = "client" crosstab_columns = ["__total__"] class ProductClientSalesMatrix2(ReportGenerator): report_model = SimpleSales date_field = "doc_date" group_by = "product" columns = ["slug", "name"] crosstab_field = "client" crosstab_columns = [ComputationField.create(Sum, "value", name="value__sum", verbose_name=_("Sales"))] class ProductClientSalesMatrixwSimpleSales2(ReportGenerator): report_model = SimpleSales2 date_field = "doc_date" group_by = "product" columns = ["slug", "name"] crosstab_field = "client" crosstab_columns = [ComputationField.create(Sum, "value", name="value__sum", verbose_name=_("Sales"))] class GeneratorClassWithAttrsAs(ReportGenerator): columns = ["get_icon", "slug", "name"] class ClientTotalBalancesWithShowEmptyFalse(ClientTotalBalance): report_slug = None default_order_by = "-__balance__" show_empty_records = False ================================================ FILE: tests/requirements.txt ================================================ -r ../requirements.txt crispy-bootstrap4 ================================================ FILE: tests/settings.py ================================================ import os SECRET_KEY = "fake-key" PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) BASE_DIR = os.path.dirname(PROJECT_DIR) DATABASES = { "default": { "ENGINE": "django.db.backends.sqlite3", "NAME": os.path.join(BASE_DIR, "db.sqlite3"), "TEST": {"NAME": "tst_db.sqlite3", "MIGRATE": False}, }, } PASSWORD_HASHERS = [ "django.contrib.auth.hashers.MD5PasswordHasher", ] INSTALLED_APPS = [ # 'django.contrib.admin', "django.contrib.contenttypes", "django.contrib.auth", "django.contrib.sites", "django.contrib.sessions", "django.contrib.messages", # 'django.contrib.admin.apps.SimpleAdminConfig', "django.contrib.staticfiles", "slick_reporting", "crispy_forms", "crispy_bootstrap4", "tests", ] ROOT_URLCONF = "tests.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", "django.template.context_processors.static", ], }, }, ] STATIC_URL = "/static/" MIGRATION_MODULES = {"contenttypes": None, "auth": None} DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" CRISPY_TEMPLATE_PACK = "bootstrap4" MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", ] ================================================ FILE: tests/templates/base.html ================================================ Title {% block content %} {% endblock %} ================================================ FILE: tests/test_dynamic_model.py ================================================ import datetime from django.db import connection, models from django.db.models import Sum, Count from django.test import TestCase from slick_reporting.dynamic_model import get_dynamic_model, _model_cache from slick_reporting.fields import ComputationField from slick_reporting.generator import ReportGenerator TABLE_NAME = "test_dynamic_sales" CREATE_TABLE_SQL = f""" CREATE TABLE {TABLE_NAME} ( id INTEGER PRIMARY KEY AUTOINCREMENT, product_name VARCHAR(100) NOT NULL, client_name VARCHAR(100), doc_date DATE NOT NULL, quantity INTEGER NOT NULL DEFAULT 0, price DECIMAL(10, 2) NOT NULL DEFAULT 0, value DECIMAL(10, 2) NOT NULL DEFAULT 0 ) """ INSERT_SQL = f""" INSERT INTO {TABLE_NAME} (product_name, client_name, doc_date, quantity, price, value) VALUES (?, ?, ?, ?, ?, ?) """ class DynamicModelTestBase(TestCase): @classmethod def setUpClass(cls): super().setUpClass() with connection.cursor() as cursor: cursor.execute(CREATE_TABLE_SQL) rows = [ ("Product A", "Client 1", "2024-01-15", 10, 5.00, 50.00), ("Product A", "Client 1", "2024-02-15", 5, 5.00, 25.00), ("Product A", "Client 2", "2024-01-20", 3, 5.00, 15.00), ("Product B", "Client 1", "2024-01-10", 7, 10.00, 70.00), ("Product B", "Client 2", "2024-02-10", 2, 10.00, 20.00), ("Product C", "Client 2", "2024-03-01", 1, 20.00, 20.00), ] cursor.executemany(INSERT_SQL, rows) @classmethod def tearDownClass(cls): with connection.cursor() as cursor: cursor.execute(f"DROP TABLE IF EXISTS {TABLE_NAME}") # Clean up model cache and app registry keys_to_remove = [k for k in _model_cache if k.endswith(f":{TABLE_NAME}")] for k in keys_to_remove: del _model_cache[k] from django.apps import apps try: del apps.all_models["slick_reporting"]["testdynamicsales"] except KeyError: pass super().tearDownClass() class TestGetDynamicModel(DynamicModelTestBase): def test_returns_model_class(self): model = get_dynamic_model(TABLE_NAME) self.assertTrue(issubclass(model, models.Model)) def test_model_meta(self): model = get_dynamic_model(TABLE_NAME) self.assertEqual(model._meta.db_table, TABLE_NAME) self.assertFalse(model._meta.managed) def test_model_fields(self): model = get_dynamic_model(TABLE_NAME) field_names = {f.name for f in model._meta.get_fields()} self.assertIn("id", field_names) self.assertIn("product_name", field_names) self.assertIn("client_name", field_names) self.assertIn("doc_date", field_names) self.assertIn("quantity", field_names) self.assertIn("price", field_names) self.assertIn("value", field_names) def test_pk_field(self): model = get_dynamic_model(TABLE_NAME) pk_field = model._meta.pk self.assertIsNotNone(pk_field) self.assertEqual(pk_field.name, "id") def test_cache_returns_same_model(self): model1 = get_dynamic_model(TABLE_NAME) model2 = get_dynamic_model(TABLE_NAME) self.assertIs(model1, model2) def test_nonexistent_table_raises(self): with self.assertRaises(ValueError) as cm: get_dynamic_model("nonexistent_table_xyz") self.assertIn("nonexistent_table_xyz", str(cm.exception)) class TestDynamicModelQuerySet(DynamicModelTestBase): def test_objects_all(self): model = get_dynamic_model(TABLE_NAME) qs = model.objects.all() self.assertEqual(qs.count(), 6) def test_filter(self): model = get_dynamic_model(TABLE_NAME) qs = model.objects.filter(product_name="Product A") self.assertEqual(qs.count(), 3) def test_values(self): model = get_dynamic_model(TABLE_NAME) products = list( model.objects.values("product_name").distinct().order_by("product_name") ) self.assertEqual(len(products), 3) self.assertEqual(products[0]["product_name"], "Product A") def test_aggregate(self): model = get_dynamic_model(TABLE_NAME) result = model.objects.aggregate(total=Sum("value")) self.assertEqual(result["total"], 200.00) def test_annotate(self): model = get_dynamic_model(TABLE_NAME) result = list( model.objects.values("product_name") .annotate(total_value=Sum("value")) .order_by("product_name") ) self.assertEqual(result[0]["product_name"], "Product A") self.assertEqual(result[0]["total_value"], 90.00) class TestReportGeneratorWithDynamicModel(DynamicModelTestBase): def test_group_by_report(self): model = get_dynamic_model(TABLE_NAME) report = ReportGenerator( report_model=model, group_by="product_name", date_field="doc_date", columns=[ "product_name", ComputationField.create(Sum, "value", name="value__sum", verbose_name="Total Value"), ], start_date=datetime.datetime(2024, 1, 1), end_date=datetime.datetime(2024, 12, 31), ) data = report.get_report_data() self.assertEqual(len(data), 3) values_by_product = {row["product_name"]: row["value__sum"] for row in data} self.assertEqual(values_by_product["Product A"], 90.00) self.assertEqual(values_by_product["Product B"], 90.00) self.assertEqual(values_by_product["Product C"], 20.00) def test_time_series_report(self): model = get_dynamic_model(TABLE_NAME) report = ReportGenerator( report_model=model, group_by="product_name", date_field="doc_date", columns=["product_name", "__time_series__"], time_series_pattern="monthly", time_series_columns=[ ComputationField.create(Sum, "value", name="ts_value", verbose_name="Value"), ], start_date=datetime.datetime(2024, 1, 1), end_date=datetime.datetime(2024, 3, 31), ) data = report.get_report_data() self.assertEqual(len(data), 3) def test_table_name_convenience_param(self): report = ReportGenerator( table_name=TABLE_NAME, group_by="product_name", date_field="doc_date", columns=[ "product_name", ComputationField.create(Sum, "value", name="value__sum2", verbose_name="Total"), ], start_date=datetime.datetime(2024, 1, 1), end_date=datetime.datetime(2024, 12, 31), ) data = report.get_report_data() self.assertEqual(len(data), 3) def test_no_group_by_report(self): model = get_dynamic_model(TABLE_NAME) report = ReportGenerator( report_model=model, group_by="", date_field="doc_date", columns=[ ComputationField.create(Sum, "value", name="total_val", verbose_name="Total"), ], start_date=datetime.datetime(2024, 1, 1), end_date=datetime.datetime(2024, 12, 31), ) data = report.get_report_data() self.assertEqual(data[0]["total_val"], 200.00) class TestReportViewTableNameImportSafety(TestCase): """Defining a ReportView with table_name must not touch the DB at class-definition time.""" def test_class_definition_does_not_hit_db(self): from slick_reporting.views import ReportView try: class _GhostTableReport(ReportView): table_name = "nonexistent_table_xyz" date_field = "doc_date" group_by = "product_name" columns = [ "product_name", ComputationField.create(Sum, "value", name="v__sum", verbose_name="V"), ] except Exception as exc: self.fail( f"Defining a ReportView with table_name must not hit the DB at " f"class-definition time, but got: {type(exc).__name__}: {exc}" ) ================================================ FILE: tests/test_generator.py ================================================ from datetime import datetime from django.db.models import Sum from django.test import TestCase from django.utils.translation import gettext_lazy as _ from slick_reporting.fields import ComputationField from slick_reporting.generator import ReportGenerator, ListViewReportGenerator from slick_reporting.helpers import get_foreign_keys from .models import OrderLine, ComplexSales from .models import SimpleSales, Client from .report_generators import ( GeneratorWithAttrAsColumn, CrosstabOnClient, GenericGenerator, GroupByCharField, TimeSeriesCustomDates, CrosstabOnField, CrosstabOnTraversingField, CrosstabCustomQueryset, TestCountField, ) from .tests import BaseTestData, year class CrosstabTests(BaseTestData, TestCase): def test_matrix_column_included(self): 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) columns = report.get_list_display_columns() self.assertEqual(len(columns), 4, columns) def test_matrix_column_position(self): report = CrosstabOnClient( columns=["__crosstab__", "name", "__total_quantity__"], crosstab_ids=[self.client1.pk], crosstab_compute_remainder=False, ) columns = report.get_list_display_columns() self.assertEqual(len(columns), 3, columns) self.assertEqual(columns[0]["name"], "value__sumCT1") report = CrosstabOnClient(crosstab_ids=[self.client1.pk], crosstab_compute_remainder=True) columns = report.get_list_display_columns() self.assertEqual(len(columns), 4, columns) def test_get_crosstab_columns(self): report = CrosstabOnClient(crosstab_ids=[self.client1.pk]) columns = report.get_list_display_columns() self.assertEqual(len(columns), 4) report = CrosstabOnClient( crosstab_ids=[self.client1.pk, self.client2.pk], crosstab_columns=["__total_quantity__", "__balance_quantity__"], ) columns = report.get_list_display_columns() self.assertEqual(len(columns), 8, [x["name"] for x in columns]) def test_get_crosstab_parsed_columns(self): """ Test important attributes are passed . :return: """ 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) def test_crosstab_on_field(self): report = CrosstabOnField() data = report.get_report_data() self.assertEqual(len(data), 2, data) self.assertEqual(data[0]["value__sumCTsales"], 90, data) self.assertEqual(data[0]["value__sumCTsales-return"], 30, data) self.assertEqual(data[0]["value__sumCT----"], 77, data) self.assertEqual(data[1]["value__sumCTsales-return"], 34, data) def test_crosstab_ids_queryset(self): # same test values as above, tests that crosstab_ids_custom_filters report = CrosstabCustomQueryset() data = report.get_report_data() self.assertEqual(len(data), 2, data) self.assertEqual(data[0]["value__sumCT0"], 90, data) self.assertEqual(data[0]["value__sumCT1"], 30, data) self.assertEqual(data[1]["value__sumCT1"], 34, data) def test_crosstab_on_traversing_field(self): report = CrosstabOnTraversingField() data = report.get_report_data() self.assertEqual(len(data), 2, data) self.assertEqual(data[0]["value__sumCTOTHER"], 120, data) self.assertEqual(data[0]["value__sumCTFEMALE"], 77, data) self.assertEqual(data[0]["value__sumCT----"], 0, data) self.assertEqual(data[1]["value__sumCTOTHER"], 34, data) def test_crosstab_time_series(self): report = ReportGenerator( report_model=ComplexSales, date_field="doc_date", group_by="product", columns=["name", "__total_quantity__"], time_series_pattern="monthly", crosstab_field="client", crosstab_columns=[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 self.assertEqual(len(time_series_columns), expected_num_of_columns, columns) data = report.get_report_data() self.assertEqual(data[0]["__total_quantity__"], 197, data) sum_o_product_1 = 0 for col in data[0]: if col.startswith("value__") and "TS" in col: sum_o_product_1 += data[0][col] self.assertEqual(sum_o_product_1, 197, data) class GeneratorReportStructureTest(BaseTestData, TestCase): @classmethod def setUpTestData(cls): super().setUpTestData() SimpleSales.objects.create( doc_date=datetime(year, 3, 2), client=cls.client3, product=cls.product3, quantity=30, price=10, ) def test_time_series_columns_inclusion(self): x = ReportGenerator( OrderLine, date_field="order__date_placed", group_by="client", columns=["name", "__time_series__"], time_series_columns=["__total_quantity__"], time_series_pattern="monthly", start_date=datetime(2020, 1, 1), end_date=datetime(2020, 12, 31), ) self.assertEqual(len(x.get_list_display_columns()), 13) def test_time_series_patterns(self): from slick_reporting.fields import TotalReportField report = ReportGenerator( OrderLine, date_field="order__date_placed", group_by="client", columns=["name", "__time_series__"], time_series_columns=["__total_quantity__"], time_series_pattern="monthly", start_date=datetime(2020, 1, 1), end_date=datetime(2020, 12, 31), ) 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)) 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")) 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")) 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") ) dates = report._get_time_series_dates("quarterly") self.assertEqual(len(dates), 4, len(dates)) dates = report._get_time_series_dates("semiannually") 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)) def not_known_pattern(): report._get_time_series_dates("each_spring") self.assertRaises(Exception, not_known_pattern) def test_time_series_custom_pattern(self): # report = ReportGenerator(OrderLine, date_field='order__date_placed', group_by='client', # columns=['name', '__time_series__'], # time_series_columns=['__total_quantity__'], time_series_pattern='monthly', # start_date=datetime(2020, 1, 1, tzinfo=pytz.timezone('utc')), # end_date=datetime(2020, 12, 31, tzinfo=pytz.timezone('utc'))) report = TimeSeriesCustomDates() dates = report._get_time_series_dates() self.assertEqual(len(dates), 3, dates) def test_time_series_columns_placeholder(self): x = ReportGenerator( OrderLine, date_field="order__date_placed", group_by="client", columns=["name"], time_series_columns=["__total_quantity__"], time_series_pattern="monthly", start_date=datetime(2020, 1, 1), end_date=datetime(2020, 12, 31), ) self.assertEqual(len(x.get_list_display_columns()), 13) def test_time_series_and_cros_tab(self): pass def test_attr_as_column(self): report = GeneratorWithAttrAsColumn() columns_data = report.get_list_display_columns() self.assertEqual(len(columns_data), 3) self.assertEqual(columns_data[0]["verbose_name"], "My Verbose Name") def test_improper_group_by(self): def load(): 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") self.assertRaises(Exception, load) def test_missing_date_field(self): def load(): ReportGenerator(report_model=OrderLine, group_by="product", date_field="", time_series_pattern="monthly") self.assertRaises(Exception, load) def test_wrong_date_field(self): def load(): ReportGenerator(report_model=OrderLine, group_by="product", date_field="not_here") self.assertRaises(Exception, load) def test_unknown_column(self): def load(): ReportGenerator( report_model=OrderLine, group_by="product", date_field="order__date_placed", columns=["product", "not_here"], ) self.assertRaises(Exception, load) def test_gather_dependencies_for_time_series(self): report = ReportGenerator( report_model=SimpleSales, group_by="client", columns=["slug", "name"], time_series_pattern="monthly", date_field="doc_date", time_series_columns=["__debit__", "__credit__", "__balance__", "__total__"], ) self.assertTrue(report._report_fields_dependencies) def test_group_by_traverse(self): report = ReportGenerator( report_model=SimpleSales, group_by="product__category", columns=[ "product__category", ComputationField.create(Sum, "value"), "__total__", ], # time_series_pattern='monthly', date_field="doc_date", # time_series_columns=['__debit__', '__credit__', '__balance__', '__total__'] ) self.assertTrue(report._report_fields_dependencies) data = report.get_report_data() self.assertNotEqual(data, []) self.assertEqual(data[0]["product__category"], "small") self.assertEqual(data[1]["product__category"], "big") def test_group_by_and_foreign_key_field(self): report = ReportGenerator( report_model=SimpleSales, group_by="client", columns=[ "name", "contact_id", "contact__address", ComputationField.create(Sum, "value"), "__total__", ], # time_series_pattern='monthly', date_field="doc_date", # time_series_columns=['__debit__', '__credit__', '__balance__', '__total__'] ) self.assertTrue(report._report_fields_dependencies) data = report.get_report_data() # import pdb; # pdb.set_trace() self.assertNotEqual(data, []) self.assertEqual(data[0]["name"], "Client 1") self.assertEqual(data[1]["name"], "Client 2") self.assertEqual(data[2]["name"], "Client 3") self.assertEqual(data[0]["contact_id"], 1) self.assertEqual(data[1]["contact_id"], 2) self.assertEqual(data[2]["contact_id"], 3) self.assertEqual(data[0]["sum__value"], 300) self.assertEqual(Client.objects.get(pk=1).contact.address, "Street 1") self.assertEqual(data[0]["contact__address"], "Street 1") self.assertEqual(data[1]["contact__address"], "Street 2") self.assertEqual(data[2]["contact__address"], "Street 3") def test_custom_group_by(self): report = ReportGenerator( report_model=SimpleSales, group_by_custom_querysets=[ SimpleSales.objects.filter(client_id__in=[self.client1.pk, self.client2.pk]), SimpleSales.objects.filter(client_id__in=[self.client3.pk]), ], group_by_custom_querysets_column_verbose_name="Custom Title", columns=[ # "__index__", is added automatically ComputationField.create(Sum, "value"), "__total__", ], date_field="doc_date", ) data = report.get_report_data() self.assertEqual(len(data), 2) self.assertEqual(data[0]["sum__value"], 900) self.assertEqual(data[1]["sum__value"], 1200) self.assertIn("__index__", data[0].keys()) 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 ComputationField.create(Sum, "value"), "__total__", ], date_field="doc_date", ) data = report.get_report_data() self.assertEqual(len(data), 2) self.assertEqual(data[0]["sum__value"], 900) self.assertEqual(data[1]["sum__value"], 1200) self.assertIn("__index__", data[0].keys()) def test_traversing_group_by_and_foreign_key_field(self): report = ReportGenerator( report_model=SimpleSales, group_by="client__contact", columns=[ "po_box", "address", "agent__name", ComputationField.create(Sum, "value"), "__total__", ], date_field="doc_date", ) self.assertTrue(report._report_fields_dependencies) data = report.get_report_data() self.assertNotEqual(data, []) # self.assertTrue(False) self.assertEqual(data[0]["address"], "Street 1") self.assertEqual(data[1]["address"], "Street 2") self.assertEqual(data[1]["agent__name"], "John") self.assertEqual(data[2]["agent__name"], "Frank") def test_traversing_group_by_sanity(self): report = ReportGenerator( report_model=SimpleSales, group_by="client__contact__agent", columns=["name", ComputationField.create(Sum, "value"), "__total__"], date_field="doc_date", ) self.assertTrue(report._report_fields_dependencies) data = report.get_report_data() self.assertNotEqual(data, []) self.assertEqual(len(data), 2) def test_db_field_column_verbose_name(self): report = GenericGenerator() field_list = report.get_list_display_columns() self.assertEqual(field_list[0]["verbose_name"], "Client Slug") def test_group_by_char_field(self): report = GroupByCharField() self.assertEqual(len(report.get_list_display_columns()), 3) # test that columns are a straight forward list class TestReportFields(BaseTestData, TestCase): def test_get_full_dependency_list(self): from slick_reporting.fields import BalanceReportField deps = BalanceReportField.get_full_dependency_list() self.assertEqual(len(deps), 1) def test_computation_field_count(self): # test case for issue #77 report = TestCountField() data = report.get_report_data() self.assertEqual(data[0]["count__id"], 5) self.assertEqual(data[1]["count__id"], 1) 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") ================================================ FILE: tests/test_pivot_generator.py ================================================ import datetime from django.db import connection from django.test import TestCase from slick_reporting.dynamic_model import get_dynamic_model, _model_cache from slick_reporting.generator import ReportGenerator from tests.models import Agent, Client, Contact, Product, SimpleSales TABLE_NAME = "test_pivot_monthly_sales" CREATE_TABLE_SQL = f""" CREATE TABLE {TABLE_NAME} ( id INTEGER PRIMARY KEY AUTOINCREMENT, product_id INTEGER NOT NULL, product_name VARCHAR(100) NOT NULL, region VARCHAR(100) NOT NULL, month DATE NOT NULL, total_sales DECIMAL(10, 2) NOT NULL DEFAULT 0, total_quantity INTEGER NOT NULL DEFAULT 0 ) """ INSERT_SQL = f""" INSERT INTO {TABLE_NAME} (product_id, product_name, region, month, total_sales, total_quantity) VALUES (?, ?, ?, ?, ?, ?) """ class PrecomputedCrosstabTestBase(TestCase): @classmethod def setUpClass(cls): super().setUpClass() with connection.cursor() as cursor: cursor.execute(CREATE_TABLE_SQL) rows = [ (1, "Product A", "North", "2024-01-01", 500, 10), (1, "Product A", "North", "2024-02-01", 600, 12), (1, "Product A", "North", "2024-03-01", 550, 11), (2, "Product B", "South", "2024-01-01", 300, 5), (2, "Product B", "South", "2024-02-01", 400, 8), # Product B has no March data — tests missing period ] cursor.executemany(INSERT_SQL, rows) @classmethod def tearDownClass(cls): with connection.cursor() as cursor: cursor.execute(f"DROP TABLE IF EXISTS {TABLE_NAME}") keys_to_remove = [k for k in _model_cache if k.endswith(f":{TABLE_NAME}")] for k in keys_to_remove: del _model_cache[k] from django.apps import apps model_key = TABLE_NAME.replace("_", "").lower() try: del apps.all_models["slick_reporting"][model_key] except KeyError: pass super().tearDownClass() class TestPrecomputedCrosstabBasic(PrecomputedCrosstabTestBase): def test_date_crosstab(self): model = get_dynamic_model(TABLE_NAME) report = ReportGenerator( report_model=model, group_by="product_id", date_field="month", crosstab_field="month", crosstab_columns=["total_sales", "total_quantity"], crosstab_precomputed=True, columns=["product_id", "__crosstab__"], start_date=datetime.datetime(2024, 1, 1), end_date=datetime.datetime(2024, 12, 31), ) data = report.get_report_data() self.assertEqual(len(data), 2) # Find Product A (id=1) prod_a = next(row for row in data if row["product_id"] == 1) # Should have sales for all 3 months self.assertEqual(prod_a["total_salesCT2024_01_01"], 500) self.assertEqual(prod_a["total_salesCT2024_02_01"], 600) self.assertEqual(prod_a["total_salesCT2024_03_01"], 550) self.assertEqual(prod_a["total_quantityCT2024_01_01"], 10) def test_missing_period_defaults_to_zero(self): model = get_dynamic_model(TABLE_NAME) report = ReportGenerator( report_model=model, group_by="product_id", date_field="month", crosstab_field="month", crosstab_columns=["total_sales"], crosstab_precomputed=True, columns=["product_id", "__crosstab__"], start_date=datetime.datetime(2024, 1, 1), end_date=datetime.datetime(2024, 12, 31), ) data = report.get_report_data() prod_b = next(row for row in data if row["product_id"] == 2) # Product B has no March data self.assertEqual(prod_b["total_salesCT2024_03_01"], 0) def test_entity_crosstab(self): """Crosstab on a non-date field (region). Note: precomputed crosstab reads pre-computed data, it does NOT aggregate. When multiple rows exist for the same (group, crosstab_value), the last row encountered wins. """ model = get_dynamic_model(TABLE_NAME) report = ReportGenerator( report_model=model, group_by="product_id", crosstab_field="region", crosstab_columns=["total_sales"], crosstab_precomputed=True, columns=["product_id", "__crosstab__"], start_date=datetime.datetime(2024, 1, 1), end_date=datetime.datetime(2024, 12, 31), ) data = report.get_report_data() self.assertEqual(len(data), 2) prod_a = next(row for row in data if row["product_id"] == 1) # Product A has multiple rows in "North" — last one wins self.assertIn("total_salesCTNorth", prod_a) self.assertGreater(prod_a["total_salesCTNorth"], 0) def test_multiple_crosstab_columns(self): model = get_dynamic_model(TABLE_NAME) report = ReportGenerator( report_model=model, group_by="product_id", date_field="month", crosstab_field="month", crosstab_columns=["total_sales", "total_quantity"], crosstab_precomputed=True, columns=["product_id", "__crosstab__"], start_date=datetime.datetime(2024, 1, 1), end_date=datetime.datetime(2024, 12, 31), ) columns_data = report.get_columns_data() col_names = [c["name"] for c in columns_data] # Should have both total_sales and total_quantity for each month self.assertIn("total_salesCT2024_01_01", col_names) self.assertIn("total_quantityCT2024_01_01", col_names) self.assertIn("total_salesCT2024_02_01", col_names) self.assertIn("total_quantityCT2024_02_01", col_names) class TestPrecomputedCrosstabMetadata(PrecomputedCrosstabTestBase): def test_crosstab_metadata_populated(self): model = get_dynamic_model(TABLE_NAME) report = ReportGenerator( report_model=model, group_by="product_id", date_field="month", crosstab_field="month", crosstab_columns=["total_sales"], crosstab_precomputed=True, columns=["product_id", "__crosstab__"], start_date=datetime.datetime(2024, 1, 1), end_date=datetime.datetime(2024, 12, 31), ) metadata = report.get_metadata() self.assertEqual(metadata["crosstab_model"], "month") self.assertTrue(len(metadata["crosstab_column_names"]) > 0) self.assertTrue(len(metadata["crosstab_column_verbose_names"]) > 0) # Time series should be empty self.assertFalse(metadata["time_series_pattern"]) self.assertEqual(metadata["time_series_column_names"], []) def test_column_computation_field_attribute(self): """Chart JS uses computation_field to match data_source.""" model = get_dynamic_model(TABLE_NAME) report = ReportGenerator( report_model=model, group_by="product_id", date_field="month", crosstab_field="month", crosstab_columns=["total_sales"], crosstab_precomputed=True, columns=["product_id", "__crosstab__"], start_date=datetime.datetime(2024, 1, 1), end_date=datetime.datetime(2024, 12, 31), ) columns_data = report.get_columns_data() ct_cols = [c for c in columns_data if "CT" in c["name"]] for col in ct_cols: self.assertEqual(col["computation_field"], "total_sales") class TestPrecomputedCrosstabWithTableName(PrecomputedCrosstabTestBase): def test_table_name_convenience(self): report = ReportGenerator( table_name=TABLE_NAME, group_by="product_id", date_field="month", crosstab_field="month", crosstab_columns=["total_sales"], crosstab_precomputed=True, columns=["product_id", "__crosstab__"], start_date=datetime.datetime(2024, 1, 1), end_date=datetime.datetime(2024, 12, 31), ) data = report.get_report_data() self.assertEqual(len(data), 2) class TestPrecomputedCrosstabDateFiltering(PrecomputedCrosstabTestBase): def test_date_filter_limits_crosstab_values(self): model = get_dynamic_model(TABLE_NAME) report = ReportGenerator( report_model=model, group_by="product_id", date_field="month", crosstab_field="month", crosstab_columns=["total_sales"], crosstab_precomputed=True, columns=["product_id", "__crosstab__"], start_date=datetime.datetime(2024, 1, 1), end_date=datetime.datetime(2024, 2, 1), ) report.get_report_data() columns_data = report.get_columns_data() ct_col_names = [c["name"] for c in columns_data if "CT" in c["name"]] # Should only have January (end_date filter is __lte so Feb 1 is included) self.assertTrue(all("2024_03_01" not in n for n in ct_col_names)) SPACES_TABLE = "test_pivot_spaces" CREATE_SPACES_TABLE_SQL = f""" CREATE TABLE {SPACES_TABLE} ( id INTEGER PRIMARY KEY AUTOINCREMENT, product_id INTEGER NOT NULL, city VARCHAR(100) NOT NULL, total_sales DECIMAL(10, 2) NOT NULL DEFAULT 0 ) """ INSERT_SPACES_SQL = f""" INSERT INTO {SPACES_TABLE} (product_id, city, total_sales) VALUES (?, ?, ?) """ class TestPrecomputedCrosstabWithSpaces(TestCase): @classmethod def setUpClass(cls): super().setUpClass() with connection.cursor() as cursor: cursor.execute(CREATE_SPACES_TABLE_SQL) rows = [ (1, "New York", 500), (1, "Los Angeles", 300), (2, "New York", 200), (2, "Los Angeles", 400), (1, "Q1/2024", 100), ] cursor.executemany(INSERT_SPACES_SQL, rows) @classmethod def tearDownClass(cls): with connection.cursor() as cursor: cursor.execute(f"DROP TABLE IF EXISTS {SPACES_TABLE}") keys_to_remove = [k for k in _model_cache if k.endswith(f":{SPACES_TABLE}")] for k in keys_to_remove: del _model_cache[k] from django.apps import apps model_key = SPACES_TABLE.replace("_", "").lower() try: del apps.all_models["slick_reporting"][model_key] except KeyError: pass super().tearDownClass() def test_crosstab_values_with_spaces(self): model = get_dynamic_model(SPACES_TABLE) report = ReportGenerator( report_model=model, group_by="product_id", crosstab_field="city", crosstab_columns=["total_sales"], crosstab_precomputed=True, columns=["product_id", "__crosstab__"], ) data = report.get_report_data() prod_1 = next(row for row in data if row["product_id"] == 1) # Spaces sanitized to underscores in column names self.assertEqual(prod_1["total_salesCTNew_York"], 500) self.assertEqual(prod_1["total_salesCTLos_Angeles"], 300) def test_crosstab_values_with_special_chars(self): model = get_dynamic_model(SPACES_TABLE) report = ReportGenerator( report_model=model, group_by="product_id", crosstab_field="city", crosstab_columns=["total_sales"], crosstab_precomputed=True, columns=["product_id", "__crosstab__"], ) data = report.get_report_data() prod_1 = next(row for row in data if row["product_id"] == 1) # Slash sanitized to underscore self.assertEqual(prod_1["total_salesCTQ1_2024"], 100) def test_verbose_name_preserves_original(self): model = get_dynamic_model(SPACES_TABLE) report = ReportGenerator( report_model=model, group_by="product_id", crosstab_field="city", crosstab_columns=["total_sales"], crosstab_precomputed=True, columns=["product_id", "__crosstab__"], ) columns_data = report.get_columns_data() ny_col = next(c for c in columns_data if "New_York" in c["name"]) self.assertIn("New York", ny_col["verbose_name"]) class TestPrecomputedCrosstabWithFKGroupBy(TestCase): """Regression: precomputed crosstab with a ForeignKey group_by returned empty rows. prepare_queryset returned .values("product_id") so each obj was {"product_id": N}. _get_record_data then looked up obj["id"] (the related model PK) which was missing, causing group_by_val="None" and every precomputed lookup to return 0. Fix: mirror the non-precomputed FK path and fetch related-model objects so obj["id"] and obj["name"] are available. """ @classmethod def setUpTestData(cls): agent = Agent.objects.create(name="Agent FK") contact = Contact.objects.create(address="Addr", agent=agent) cls.product1 = Product.objects.create(name="Prod FK 1", category="small", sku="fk1", notes="", slug="fk1") cls.product2 = Product.objects.create(name="Prod FK 2", category="medium", sku="fk2", notes="", slug="fk2") cls.client1 = Client.objects.create(name="Cli FK 1", notes="", slug="cfk1") cls.client1.contact = contact cls.client1.save() cls.client2 = Client.objects.create(name="Cli FK 2", notes="", slug="cfk2") cls.client2.contact = contact cls.client2.save() SimpleSales.objects.create( slug="s1", doc_date=datetime.datetime(2024, 1, 15), client=cls.client1, product=cls.product1, quantity=5, price=100, created_at=datetime.datetime(2024, 1, 15), ) SimpleSales.objects.create( slug="s2", doc_date=datetime.datetime(2024, 2, 15), client=cls.client2, product=cls.product2, quantity=3, price=200, created_at=datetime.datetime(2024, 2, 15), ) def test_rows_populated_with_fk_group_by(self): report = ReportGenerator( report_model=SimpleSales, group_by="product", date_field="doc_date", crosstab_field="client", crosstab_columns=["value"], crosstab_precomputed=True, columns=["name", "__crosstab__"], start_date=datetime.datetime(2024, 1, 1), end_date=datetime.datetime(2024, 12, 31), ) data = report.get_report_data() self.assertEqual(len(data), 2, "Expected one row per product") names = {row["name"] for row in data} self.assertEqual(names, {"Prod FK 1", "Prod FK 2"}, "Product names must be populated (not empty strings)") prod1_row = next(row for row in data if row["name"] == "Prod FK 1") client1_col = f"valueCT{self.client1.pk}" self.assertEqual( prod1_row[client1_col], 500, "Prod FK 1 / Client 1 value should be 500 (5 * 100); was 0 before the fix", ) ================================================ FILE: tests/tests.py ================================================ import datetime from unittest import skip from unittest.mock import patch from django.contrib.auth import get_user_model from django.db.models import Count from django.test import TestCase, override_settings from django.urls import reverse from django.utils.timezone import now 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 from tests.report_generators import ( ClientTotalBalance, ProductClientSalesMatrix2, GroupByCharField, GroupByCharFieldPlusTimeSeries, TimeSeriesWithOutGroupBy, ProductClientSalesMatrixwSimpleSales2, ) from . import report_generators from .models import ( Client, Contact, Product, SimpleSales, UserJoined, SalesWithFlag, ComplexSales, TaxCode, ProductCustomID, SalesProductWithCustomID, Agent, SimpleSales2, ) User = get_user_model() SUPER_LOGIN = dict(username="superlogin", password="password") year = now().year class BaseTestData: databases = "__all__" @classmethod def setUpTestData(cls): super().setUpTestData() User.objects.create_superuser("super", None, "secret") user = User.objects.create(is_superuser=True, is_staff=True, **SUPER_LOGIN) limited_user = User.objects.create_user( is_superuser=False, is_staff=True, username="limited", password="password" ) cls.user = user cls.limited_user = limited_user agent = Agent.objects.create(name="John") agent2 = Agent.objects.create(name="Frank") cls.client1 = Client.objects.create(name="Client 1", sex="MALE") cls.client1.contact = Contact.objects.create(address="Street 1", agent=agent) cls.client1.save() cls.client2 = Client.objects.create(name="Client 2", sex="FEMALE") cls.client2.contact = Contact.objects.create(address="Street 2", agent=agent) cls.client2.save() cls.client3 = Client.objects.create(name="Client 3", sex="OTHER") cls.client3.contact = Contact.objects.create(address="Street 3", agent=agent2) 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.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), client=cls.client1, product=cls.product1, quantity=10, price=10, created_at=datetime.datetime(year, 1, 5), ) SimpleSales.objects.create( doc_date=datetime.datetime(year, 2, 2), client=cls.client1, product=cls.product1, quantity=10, price=10, created_at=datetime.datetime(year, 2, 3), ) SimpleSales.objects.create( doc_date=datetime.datetime(year, 3, 2), client=cls.client1, product=cls.product1, quantity=10, price=10, created_at=datetime.datetime(year, 3, 3), ) # client 2 SimpleSales.objects.create( doc_date=datetime.datetime(year, 1, 2), client=cls.client2, product=cls.product1, quantity=20, price=10, ) SimpleSales.objects.create( doc_date=datetime.datetime(year, 2, 2), client=cls.client2, product=cls.product1, quantity=20, price=10, ) SimpleSales.objects.create( doc_date=datetime.datetime(year, 3, 2), client=cls.client2, product=cls.product1, quantity=20, price=10, ) # client 3 SimpleSales.objects.create( doc_date=datetime.datetime(year, 1, 2), client=cls.client3, product=cls.product1, quantity=30, price=10, ) SimpleSales.objects.create( doc_date=datetime.datetime(year, 2, 2), client=cls.client3, product=cls.product1, quantity=30, price=10, ) SimpleSales.objects.create( doc_date=datetime.datetime(year, 3, 2), client=cls.client3, product=cls.product1, quantity=30, price=10, ) SimpleSales2.objects.create( doc_date=datetime.datetime(year, 1, 2), client=cls.client1, product=cls.product1, quantity=10, price=10, created_at=datetime.datetime(year, 1, 5), ) SimpleSales2.objects.create( doc_date=datetime.datetime(year, 2, 2), client=cls.client1, product=cls.product1, quantity=10, price=10, created_at=datetime.datetime(year, 2, 3), ) SimpleSales2.objects.create( doc_date=datetime.datetime(year, 3, 2), client=cls.client1, product=cls.product1, quantity=10, price=10, created_at=datetime.datetime(year, 3, 3), ) # client 2 SimpleSales2.objects.create( doc_date=datetime.datetime(year, 1, 2), client=cls.client2, product=cls.product1, quantity=20, price=10, ) SimpleSales2.objects.create( doc_date=datetime.datetime(year, 2, 2), client=cls.client2, product=cls.product1, quantity=20, price=10, ) SimpleSales2.objects.create( doc_date=datetime.datetime(year, 3, 2), client=cls.client2, product=cls.product1, quantity=20, price=10, ) # client 3 SimpleSales2.objects.create( doc_date=datetime.datetime(year, 1, 2), client=cls.client3, product=cls.product1, quantity=30, price=10, ) SimpleSales2.objects.create( doc_date=datetime.datetime(year, 2, 2), client=cls.client3, product=cls.product1, quantity=30, price=10, ) SimpleSales2.objects.create( doc_date=datetime.datetime(year, 3, 2), client=cls.client3, product=cls.product1, quantity=30, price=10, ) cls.tax1 = TaxCode.objects.create(name="State", tax=8) # Added three times cls.tax2 = TaxCode.objects.create(name="Vat reduced", tax=5) # Added two times cls.tax3 = TaxCode.objects.create(name="Vat full", tax=20) # Added one time sale1 = ComplexSales.objects.create( doc_date=datetime.datetime(year, 3, 2), client=cls.client3, product=cls.product1, quantity=30, price=10, flag="sales", ) sale2 = ComplexSales.objects.create( doc_date=datetime.datetime(year, 3, 2), client=cls.client3, product=cls.product1, quantity=30, price=10, flag="sales", ) sale3 = ComplexSales.objects.create( doc_date=datetime.datetime(year, 3, 2), client=cls.client3, product=cls.product1, quantity=30, price=10, flag="sales", ) sale4 = ComplexSales.objects.create( doc_date=datetime.datetime(year, 3, 2), client=cls.client3, product=cls.product1, quantity=30, price=10, flag="sales-return", ) sale4 = ComplexSales.objects.create( doc_date=datetime.datetime(year, 3, 2), client=cls.client3, product=cls.product2, quantity=34, price=10, flag="sales-return", ) ComplexSales.objects.create( doc_date=datetime.datetime(year, 3, 2), client=cls.client2, product=cls.product1, quantity=77, price=10, flag="", ) sale1.tax.add(cls.tax1) sale1.tax.add(cls.tax2) sale2.tax.add(cls.tax1) sale2.tax.add(cls.tax3) sale3.tax.add(cls.tax1) sale4.tax.add(cls.tax2) SalesProductWithCustomID.objects.create( doc_date=datetime.datetime(year, 1, 2), client=cls.client1, product=cls.product_w_custom_id1, quantity=10, price=10, created_at=datetime.datetime(year, 1, 5), ) SalesProductWithCustomID.objects.create( doc_date=datetime.datetime(year, 2, 2), client=cls.client1, product=cls.product_w_custom_id1, quantity=10, price=10, created_at=datetime.datetime(year, 2, 3), ) SalesProductWithCustomID.objects.create( doc_date=datetime.datetime(year, 3, 2), client=cls.client1, product=cls.product_w_custom_id2, quantity=10, price=10, created_at=datetime.datetime(year, 3, 3), ) # @override_settings(ROOT_URLCONF='reporting_tests.urls', RA_CACHE_REPORTS=False, USE_TZ=False) class ReportTest(BaseTestData, TestCase): def test_client_balance(self): report = report_generators.ClientTotalBalance() data = report.get_report_data() self.assertEqual(data[0].get("__balance__"), 300, data[0]) def test_compute_from_queryset(self): report = report_generators.TotalBalanceWithQueryset() data = report.get_report_data() self.assertEqual(data, []) def test_product_total_sales(self): report = report_generators.ProductTotalSalesProductWithCustomID() data = report.get_report_data() self.assertEqual(data[0]["__balance__"], 200) self.assertEqual(data[1]["__balance__"], 100) def test_product_total_sales_product_custom_id(self): report = report_generators.ProductTotalSales() data = report.get_report_data() self.assertEqual(data[0]["__balance__"], 1800) self.assertEqual(data[0]["get_object_sku"], "A1B1") self.assertEqual( data[0]["average_value"], data[0]["__balance__"] / data[0]["__balance_quantity__"], ) def test_product_total_sales_with_percentage(self): report = report_generators.ProductTotalSalesWithPercentage() data = report.get_report_data() self.assertEqual(data[2]["__percent_to_total_balance__"], 50) @override_settings( SLICK_REPORTING_DEFAULT_START_DATE=datetime.datetime(2020, 1, 1), SLICK_REPORTING_DEFAULT_END_DATE=datetime.datetime(2021, 1, 1), ) def test_product_total_sales_with_changed_dated(self): report = report_generators.ProductTotalSales() data = report.get_report_data() self.assertEqual(len(data), 0) def test_client_client_sales_monthly(self): report = report_generators.ClientSalesMonthlySeries() data = report.get_report_data() self.assertEqual(data[0].get("__balance__TS%s0301" % year), 200, data[0]) self.assertEqual(data[0]["__balance__TS%s0201" % year], 100) self.assertEqual(data[0]["__total__TS%s0401" % year], 100) self.assertEqual(data[0]["__total__TS%s0301" % year], 100) self.assertEqual(data[0]["__total__TS%s0201" % year], 100) self.assertEqual(data[0]["__debit__TS%s0401" % year], 100) self.assertEqual(data[0]["__debit__TS%s0301" % year], 100) self.assertEqual(data[0]["__debit__TS%s0201" % year], 100) self.assertEqual(data[2]["__debit__TS%s0401" % year], 300) self.assertEqual(data[2]["__debit__TS%s0301" % year], 300) self.assertEqual(data[2]["__debit__TS%s0201" % year], 300) # todo add __fb__ to time series and check the balance def test_productclientsalesmatrix(self): 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) self.assertEqual(data[0]["__total__CT----"], 900) def test_productclientsalesmatrix_no_remainder(self): report = report_generators.ProductClientSalesMatrix( crosstab_ids=[self.client1.pk, self.client2.pk], crosstab_compute_remainder=False, ) 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) def test_show_empty_records(self): report = report_generators.ClientTotalBalance() data = report.get_report_data() with_show_empty_len = len(data) wo_show_empty = report_generators.ClientTotalBalance(show_empty_records=False) self.assertNotEqual(with_show_empty_len, wo_show_empty) # 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) data = report.get_report_data() self.assertEqual(len(data), 1, data) report = ClientTotalBalance(kwargs_filters={"client": self.client1.pk}, show_empty_records=False) data = report.get_report_data() self.assertEqual(len(data), 1, data) def test_view_filter_to_field_set(self): report_generator = ReportGenerator( report_model=SimpleSales2, date_field="doc_date", group_by="client", columns=["slug", "name"], time_series_pattern="monthly", time_series_columns=["__total__", "__balance__"], ) data = report_generator.get_report_data() with patch("slick_reporting.helpers.user_test_function", return_value=True): response = self.client.get( reverse("report-to-field-set"), data={ "client_id": [self.client2.name, self.client1.name], }, HTTP_X_REQUESTED_WITH="XMLHttpRequest", ) self.assertEqual(response.status_code, 200) response.json() self.assertTrue(len(data), 2) # 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) 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) data = report.get_report_data() self.assertEqual(len(data), 1, data) def test_timeseries_without_group(self): report = TimeSeriesWithOutGroupBy() data = report.get_report_data() self.assertEqual(data[0][f"__total__TS{year}0201"], 600) def test_many_to_many_group_by(self): field_registry.register(ComputationField.create(Count, "tax__name", "tax__count")) report_generator = ReportGenerator( report_model=ComplexSales, date_field="doc_date", group_by="tax__name", columns=["tax__name", "tax__count"], ) data = report_generator.get_report_data() self.assertEqual(len(data), 4) # 3 taxes + 1 empty self.assertEqual(data[0]["tax__name"], "State") self.assertEqual(data[0]["tax__count"], 3) self.assertEqual(data[1]["tax__name"], "Vat reduced") self.assertEqual(data[1]["tax__count"], 2) self.assertEqual(data[2]["tax__name"], "Vat full") self.assertEqual(data[2]["tax__count"], 1) class TestView(BaseTestData, TestCase): def test_view(self): response = self.client.get( reverse("report1"), HTTP_X_REQUESTED_WITH="XMLHttpRequest", ) self.assertEqual(response.status_code, 200) view_report_data = response.json()["data"] report_generator = ReportGenerator( report_model=SimpleSales, date_field="doc_date", group_by="client", columns=["slug", "name"], time_series_pattern="monthly", time_series_columns=["__total__", "__balance__"], ) self.assertTrue(view_report_data) self.assertEqual(view_report_data, report_generator.get_report_data()) def test_qs_only(self): response = self.client.get( reverse("queryset-only"), HTTP_X_REQUESTED_WITH="XMLHttpRequest", ) self.assertEqual(response.status_code, 200) view_report_data = response.json()["data"] report_generator = ReportGenerator( report_model=SimpleSales, date_field="doc_date", group_by="client", columns=["slug", "name"], time_series_pattern="monthly", time_series_columns=["__total__", "__balance__"], ) self.assertTrue(view_report_data) self.assertEqual(view_report_data, report_generator.get_report_data()) def test_view_filter(self): report_generator = ReportGenerator( report_model=SimpleSales, date_field="doc_date", group_by="client", columns=["slug", "name"], time_series_pattern="monthly", time_series_columns=["__total__", "__balance__"], kwargs_filters={"client_id__in": [self.client1.pk, self.client2.pk]}, ) data = report_generator.get_report_data() response = self.client.get( reverse("report1"), data={ "client_id": [self.client2.pk, self.client1.pk], }, HTTP_X_REQUESTED_WITH="XMLHttpRequest", ) self.assertEqual(response.status_code, 200) self.assertTrue(len(data), 2) view_report_data = response.json() self.assertEqual(view_report_data["data"], data) def test_view_filter_to_field_set(self): report_generator = ReportGenerator( report_model=SimpleSales2, date_field="doc_date", group_by="client", columns=["slug", "name"], time_series_pattern="monthly", time_series_columns=["__total__", "__balance__"], ) data = report_generator.get_report_data() response = self.client.get( reverse("report-to-field-set"), HTTP_X_REQUESTED_WITH="XMLHttpRequest", ) self.assertEqual(response.status_code, 200) self.assertTrue(len(data), 2) view_report_data = response.json() self.assertEqual(view_report_data["data"], data) def test_ajax(self): report_generator = ReportGenerator( report_model=SimpleSales, date_field="doc_date", group_by="client", columns=["slug", "name"], time_series_pattern="monthly", time_series_columns=["__total__", "__balance__"], ) data = report_generator.get_report_data() 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) def test_crosstab_report_view(self): from .report_generators import ProductClientSalesMatrix data = ProductClientSalesMatrix( crosstab_compute_remainder=True, crosstab_ids=[self.client1.pk, self.client2.pk], ).get_report_data() response = self.client.get(reverse("product_crosstab_client")) self.assertEqual(response.status_code, 200) response = self.client.get( reverse("product_crosstab_client"), data={ "client_id": [self.client1.pk, self.client2.pk], "crosstab_compute_remainder": True, }, HTTP_X_REQUESTED_WITH="XMLHttpRequest", ) self.assertEqual(response.status_code, 200) view_report_data = response.json() self.assertEqual(view_report_data["data"], data) def test_crosstab_report_view_clumns_on_fly(self): data = ProductClientSalesMatrix2( crosstab_compute_remainder=True, crosstab_ids=[self.client1.pk, self.client2.pk], ).get_report_data() response = self.client.get( reverse("crosstab-columns-on-fly"), data={ "client_id": [self.client1.pk, self.client2.pk], "crosstab_compute_remainder": True, }, HTTP_X_REQUESTED_WITH="XMLHttpRequest", ) self.assertEqual(response.status_code, 200) view_report_data = response.json() self.assertEqual(view_report_data["data"], data, view_report_data) def test_crosstab_report_view_to_field_set(self): from .report_generators import ProductClientSalesMatrixToFieldSet data = ProductClientSalesMatrixToFieldSet( crosstab_compute_remainder=True, crosstab_ids=[self.client1.name, self.client2.name], ).get_report_data() response = self.client.get(reverse("product_crosstab_client_to_field_set")) self.assertEqual(response.status_code, 200) response = self.client.get( reverse("product_crosstab_client_to_field_set"), data={ "client_id": [self.client1.name, self.client2.name], "crosstab_compute_remainder": True, }, HTTP_X_REQUESTED_WITH="XMLHttpRequest", ) self.assertEqual(response.status_code, 200) view_report_data = response.json() self.assertEqual(view_report_data["data"], data) def test_crosstab_report_view_clumns_on_fly_to_field_set(self): data = ProductClientSalesMatrixwSimpleSales2( crosstab_compute_remainder=True, crosstab_ids=[self.client1.name, self.client2.name], ).get_report_data() response = self.client.get( reverse("crosstab-columns-on-fly-to-field-set"), data={ "client_id": [self.client1.name, self.client2.name], "crosstab_compute_remainder": True, }, HTTP_X_REQUESTED_WITH="XMLHttpRequest", ) self.assertEqual(response.status_code, 200) view_report_data = response.json() self.assertEqual(view_report_data["data"], data, view_report_data["data"]) def test_chart_settings(self): response = self.client.get( reverse("product_crosstab_client"), data={ "client_id": [self.client1.pk, self.client2.pk], "crosstab_compute_remainder": True, }, HTTP_X_REQUESTED_WITH="XMLHttpRequest", ) self.assertEqual(response.status_code, 200) data = response.json() self.assertTrue(data["chart_settings"][0]["id"] != "") self.assertTrue(data["chart_settings"][0]["title"], "awesome report title") @skip def test_error_on_missing_date_field(self): def test_function(): class TotalClientSales(ReportView): report_model = SimpleSales self.assertRaises(TypeError, test_function) class TestReportFieldRegistry(TestCase): def test_unregister(self): # unregister a field that we know exists field_registry.unregister("__balance__") self.assertNotIn("__balance__", field_registry.get_all_report_fields_names()) # bring it back again as later tests using it would fail field_registry.register(BalanceReportField) def test_registering_new(self): def register(): class ReportFieldWDuplicatedName(ComputationField): name = "__total_field__" calculation_field = "field" field_registry.register(ReportFieldWDuplicatedName) register() self.assertIn("__total_field__", field_registry.get_all_report_fields_names()) def test_already_registered(self): def register(): class ReportFieldWDuplicatedName(ComputationField): name = "__total__" field_registry.register(ReportFieldWDuplicatedName) with self.assertRaises(Exception): register() def test_unregister_a_non_existent(self): def register(): field_registry.unregister("__a_weird_name__") with self.assertRaises(Exception): register() def test_get_non_existent_field(self): def register(): return field_registry.get_field_by_name("__a_weird_name__") with self.assertRaises(Exception): register() def test_creating_a_report_field_on_the_fly(self): from django.db.models import Sum 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 = ComputationField.create(Sum, "value") self.assertNotIn(name, field_registry.get_all_report_fields_names()) 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="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)) def test_joined_per_day(self): field_registry.register(ComputationField.create(Count, "id", "count__id")) report_generator = ReportGenerator( report_model=UserJoined, date_field="date_joined", group_by="date_joined", start_date=datetime.date(2020, 1, 1), end_date=datetime.date(2020, 1, 10), columns=["date_joined", "count__id"], ) data = report_generator.get_report_data() self.assertEqual(len(data), 3) self.assertEqual(data[0]["count__id"], 1) self.assertEqual(data[1]["count__id"], 1) self.assertEqual(data[2]["count__id"], 2) class TestGroupByFlag(TestCase): databases = "__all__" @classmethod def setUpTestData(cls): super().setUpTestData() User.objects.create_superuser("super", None, "secret") user = User.objects.create(is_superuser=True, is_staff=True, **SUPER_LOGIN) limited_user = User.objects.create_user( is_superuser=False, is_staff=True, username="limited", password="password" ) cls.user = user cls.limited_user = limited_user cls.client1 = Client.objects.create(name="Client 1") cls.client2 = Client.objects.create(name="Client 2") cls.client3 = Client.objects.create(name="Client 3") cls.clientIdle = Client.objects.create(name="Client Idle") cls.product1 = Product.objects.create(name="Product 1") cls.product2 = Product.objects.create(name="Product 2") cls.product3 = Product.objects.create(name="Product 3") SalesWithFlag.objects.create( doc_date=datetime.datetime(year, 1, 1), client=cls.client1, product=cls.product1, quantity=10, price=10, created_at=datetime.datetime(year, 1, 5), ) SalesWithFlag.objects.create( doc_date=datetime.datetime(year, 2, 1), client=cls.client1, product=cls.product1, quantity=10, price=10, created_at=datetime.datetime(year, 2, 3), ) SalesWithFlag.objects.create( doc_date=datetime.datetime(year, 3, 1), client=cls.client1, product=cls.product1, quantity=10, price=10, created_at=datetime.datetime(year, 3, 3), ) # client 2 SalesWithFlag.objects.create( doc_date=datetime.datetime(year, 1, 1), client=cls.client2, product=cls.product1, quantity=20, price=10, ) SalesWithFlag.objects.create( doc_date=datetime.datetime(year, 2, 1), client=cls.client2, product=cls.product1, quantity=20, price=10, ) SalesWithFlag.objects.create( doc_date=datetime.datetime(year, 3, 1), client=cls.client2, product=cls.product1, quantity=20, price=10, ) # client 3 SalesWithFlag.objects.create( doc_date=datetime.datetime(year, 1, 1), client=cls.client3, product=cls.product1, quantity=30, price=10, ) SalesWithFlag.objects.create( doc_date=datetime.datetime(year, 2, 1), client=cls.client3, product=cls.product1, quantity=30, price=10, ) SalesWithFlag.objects.create( doc_date=datetime.datetime(year, 3, 1), client=cls.client3, product=cls.product1, quantity=30, price=10, ) SalesWithFlag.objects.create( doc_date=datetime.datetime(year, 3, 1), client=cls.client3, product=cls.product1, quantity=25, price=10, flag="sales-return", ) def test_group_by_flag(self): report = GroupByCharField() data = report.get_report_data() self.assertEqual(data[0]["sum__quantity"], 180) self.assertEqual(data[1]["sum__quantity"], 25) def test_group_by_flag_time_series(self): report = GroupByCharFieldPlusTimeSeries() data = report.get_report_data() self.assertEqual(len(data), 2) self.assertEqual(data[1]["sum__quantity"], 25) self.assertEqual(data[1][f"sum__quantityTS{year}0401"], 25) ================================================ FILE: tests/urls.py ================================================ from django.urls import path from . import views urlpatterns = [ path("report1/", views.MonthlyProductSales.as_view(), name="report1"), path( "product_crosstab_client/", views.ProductClientSalesMatrix.as_view(), name="product_crosstab_client", ), path( "report-to-field-set/", views.MonthlyProductSalesToFIeldSet.as_view(), name="report-to-field-set", ), path( "product_crosstab_client/", views.ProductClientSalesMatrix.as_view(), name="product_crosstab_client", ), path( "product_crosstab_client-to_field-set/", views.ProductClientSalesMatrixToFieldSet.as_view(), name="product_crosstab_client_to_field_set", ), path( "crosstab-columns-on-fly/", views.CrossTabColumnOnFly.as_view(), name="crosstab-columns-on-fly", ), path( "crosstab-columns-on-fly-to-field-set/", views.CrossTabColumnOnFlyToFieldSet.as_view(), name="crosstab-columns-on-fly-to-field-set", ), path( "queryset-only/", views.MonthlyProductSalesWQS.as_view(), name="queryset-only" ), ] ================================================ FILE: tests/views.py ================================================ from slick_reporting.views import ReportView 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 _ class MonthlyProductSales(ReportView): report_model = SimpleSales date_field = "doc_date" group_by = "client" columns = ["slug", "name"] time_series_pattern = "monthly" time_series_columns = ["__total__", "__balance__"] class MonthlyProductSalesToFIeldSet(ReportView): report_model = SimpleSales2 date_field = "doc_date" group_by = "client" columns = ["slug", "name"] time_series_pattern = "monthly" time_series_columns = ["__total__", "__balance__"] class ProductClientSalesMatrix(ReportView): report_title = "awesome report title" report_model = SimpleSales date_field = "doc_date" group_by = "product" columns = ["slug", "name"] crosstab_field = "client" crosstab_columns = [TotalReportField] chart_settings = [ { "type": "pie", "date_source": "__total__", "title_source": "__total__", } ] class ProductClientSalesMatrixToFieldSet(ReportView): report_title = "awesome report title" report_model = SimpleSales2 date_field = "doc_date" group_by = "product" columns = ["slug", "name"] crosstab_field = "client" crosstab_columns = ["__total__"] chart_settings = [ { "type": "pie", "date_source": "__total__", "title_source": "__total__", } ] class CrossTabColumnOnFly(ReportView): report_title = "awesome report title" report_model = SimpleSales date_field = "doc_date" group_by = "product" columns = ["slug", "name"] crosstab_field = "client" crosstab_columns = [ ComputationField.create( Sum, "value", name="value__sum", verbose_name=_("Sales") ) ] chart_settings = [ { "type": "pie", "date_source": "value__sum", "title_source": "name", } ] class CrossTabColumnOnFlyToFieldSet(ReportView): report_title = "awesome report title" report_model = SimpleSales2 date_field = "doc_date" group_by = "product" columns = ["slug", "name"] crosstab_field = "client" crosstab_columns = [ ComputationField.create( Sum, "value", name="value__sum", verbose_name=_("Sales") ) ] chart_settings = [ { "type": "pie", "date_source": "value__sum", "title_source": "name", } ] class MonthlyProductSalesWQS(ReportView): queryset = SimpleSales.objects.all() date_field = "doc_date" group_by = "client" columns = ["slug", "name"] time_series_pattern = "monthly" time_series_columns = [TotalReportField, "__balance__"] class TaxSales(ReportView): # report_model = SimpleSales queryset = ComplexSales.objects.all() date_field = "doc_date" group_by = "tax__name" columns = [ "tax__name", ComputationField.create( Count, "tax", name="tax__count", verbose_name=_("Sales") ), ] chart_settings = [ { "type": "pie", "date_source": "tax__count", "title_source": "tax__name", } ] class MonthlyProductSalesToFIeldSet(ReportView): report_model = SimpleSales2 date_field = "doc_date" group_by = "client" columns = ["slug", "name"] time_series_pattern = "monthly" time_series_columns = ["__total__", "__balance__"] class TaxSales(ReportView): # report_model = SimpleSales queryset = ComplexSales.objects.all() date_field = "doc_date" group_by = "tax__name" columns = [ "tax__name", ComputationField.create( Count, "tax", name="tax__count", verbose_name=_("Sales") ), ] chart_settings = [ { "type": "pie", "date_source": "tax__count", "title_source": "tax__name", } ]