diff --git a/CHANGELOG.md b/CHANGELOG.md index add6752..3777d6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,13 @@ # Changelog All notable changes to this project will be documented in this file. +## [0.3.0] - 2020-11-23 + +- Ad 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 diff --git a/README.rst b/README.rst index f46d3eb..6ed1ee7 100644 --- a/README.rst +++ b/README.rst @@ -74,7 +74,7 @@ You can use ``SampleReportView`` *which is an enhanced subclass of ``django.view # columns = ['title', '__total_quantity__', '__total__'] # in your urls.py - path('url-to-report', TotalProductSales.as_view()) + path('path-to-report', TotalProductSales.as_view()) This will return a page, with a table looking like @@ -213,4 +213,4 @@ If you like this package, chances are you may like those packages too! `Django Ra ERP Framework `_ A framework to build business solutions with ease. -If you find this project useful or proimosing , You can support us by a github ⭐ +If you find this project useful or promising , You can support us by a github ⭐ diff --git a/docs/source/conf.py b/docs/source/conf.py index 14ab01c..127ccba 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -24,7 +24,7 @@ master_doc = 'index' # The full version, including alpha/beta/rc tags -release = '0.2.7' +release = '0.3.0' # -- General configuration --------------------------------------------------- diff --git a/slick_reporting/__init__.py b/slick_reporting/__init__.py index 3026dce..781845d 100644 --- a/slick_reporting/__init__.py +++ b/slick_reporting/__init__.py @@ -1,6 +1,6 @@ default_app_config = 'slick_reporting.apps.ReportAppConfig' -VERSION = (0, 2, 9) +VERSION = (0, 3, 0) -__version__ = '0.2.9' +__version__ = '0.3.0' diff --git a/slick_reporting/fields.py b/slick_reporting/fields.py index 6585d2d..d49b64b 100644 --- a/slick_reporting/fields.py +++ b/slick_reporting/fields.py @@ -47,13 +47,14 @@ class BaseReportField(object): _debit_and_credit = True @classmethod - def create(cls, method, field, name=None, verbose_name=None): + def create(cls, method, field, name=None, verbose_name=None, is_summable=True): """ Creates a ReportField class on the fly - :param method: - :param field: - :param name: - :param verbose_name: + :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: @@ -66,10 +67,10 @@ def create(cls, method, field, name=None, verbose_name=None): 'name': name, 'verbose_name': verbose_name, 'calculation_field': field, - 'calculation_method': method + 'calculation_method': method, + 'is_summable': is_summable, }) - cls._field_registry.register(report_klass) - return name + return report_klass def __init__(self, plus_side_q=None, minus_side_q=None, report_model=None, diff --git a/slick_reporting/generator.py b/slick_reporting/generator.py index edd1d0f..e55503d 100644 --- a/slick_reporting/generator.py +++ b/slick_reporting/generator.py @@ -7,6 +7,7 @@ from django.core.exceptions import ImproperlyConfigured, FieldDoesNotExist from django.db.models import Q +from .fields import BaseReportField from .helpers import get_field_from_query_text from .registry import field_registry @@ -367,17 +368,32 @@ def get_report_data(self): data = [get_record_data(obj, all_columns) for obj in main_queryset] return data - def _parse(self): + @classmethod + def check_columns(cls, columns, group_by, report_model, ): + """ + 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 + :return: List of dict, each dict contains relevant data to the respective field in `columns` + """ + group_by_model = None + if group_by: + group_by_field = [x for x in report_model._meta.fields if x.name == group_by][0] + group_by_model = group_by_field.related_model - if self.group_by: - self.group_by_field = [x for x in self.report_model._meta.fields if x.name == self.group_by][0] - self.group_by_model = self.group_by_field.related_model + parsed_columns = [] + for col in columns: + magic_field_class = None + attr = None + + if type(col) is str: + attr = getattr(cls, col, None) + elif issubclass(col, BaseReportField): + magic_field_class = col - self.parsed_columns = [] - for col in self.columns: - attr = getattr(self, col, None) try: - magic_field_class = field_registry.get_field_by_name(col) + magic_field_class = magic_field_class or field_registry.get_field_by_name(col) except KeyError: magic_field_class = None @@ -395,7 +411,7 @@ def _parse(self): # These are placeholder not real computation field continue - col_data = {'name': col, + col_data = {'name': magic_field_class.name, 'verbose_name': magic_field_class.verbose_name, 'source': 'magic_field', 'ref': magic_field_class, @@ -404,7 +420,7 @@ def _parse(self): } else: # A database field - model_to_use = self.group_by_model if self.group_by else self.report_model + model_to_use = group_by_model if group_by else report_model try: if '__' in col: # A traversing link order__client__email @@ -413,7 +429,8 @@ def _parse(self): field = model_to_use._meta.get_field(col) except FieldDoesNotExist: raise FieldDoesNotExist( - f'Field "{col}" not found as an attribute to the generator class, nor as computation field, nor as a database column for the model "{model_to_use._meta.model_name}"') + f'Field "{col}" not found either as an attribute to the generator class {cls}, ' + 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), @@ -421,11 +438,14 @@ def _parse(self): 'ref': field, 'type': field.get_internal_type() } - self.parsed_columns.append(col_data) + parsed_columns.append(col_data) + return parsed_columns - self._parsed_columns = list(self.parsed_columns) - self._time_series_parsed_columns = self.get_time_series_parsed_columns() - self._crosstab_parsed_columns = self.get_crosstab_parsed_columns() + def _parse(self): + self.parsed_columns = self.check_columns(self.columns, self.group_by, self.report_model) + self._parsed_columns = list(self.parsed_columns) + self._time_series_parsed_columns = self.get_time_series_parsed_columns() + self._crosstab_parsed_columns = self.get_crosstab_parsed_columns() def get_database_columns(self): return [col['name'] for col in self.parsed_columns if col['source'] == 'database'] @@ -467,7 +487,13 @@ def get_time_series_parsed_columns(self): for dt in series: for col in cols: - magic_field_class = field_registry.get_field_by_name(col) + magic_field_class = None + + if type(col) is str: + magic_field_class = field_registry.get_field_by_name(col) + elif issubclass(col, BaseReportField): + magic_field_class = col + _values.append({ 'name': col + 'TS' + dt[1].strftime('%Y%m%d'), 'original_name': col, @@ -547,7 +573,12 @@ def get_crosstab_parsed_columns(self): ids_length = len(ids) - 1 for counter, id in enumerate(ids): for col in report_columns: - magic_field_class = field_registry.get_field_by_name(col) + magic_field_class = None + if type(col) is str: + magic_field_class = field_registry.get_field_by_name(col) + elif issubclass(col, BaseReportField): + magic_field_class = col + output_cols.append({ 'name': f'{col}CT{id}', 'original_name': col, diff --git a/slick_reporting/helpers.py b/slick_reporting/helpers.py index 45263fb..fffcb63 100644 --- a/slick_reporting/helpers.py +++ b/slick_reporting/helpers.py @@ -29,6 +29,13 @@ def get_foreign_keys(model): 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 diff --git a/slick_reporting/registry.py b/slick_reporting/registry.py index 438aeb3..652cdeb 100644 --- a/slick_reporting/registry.py +++ b/slick_reporting/registry.py @@ -16,7 +16,7 @@ def register(self, report_field, override=False): :return: report_field passed """ if report_field.name in self._registry and not override: - raise AlreadyRegistered('This field is already registered') + 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 diff --git a/slick_reporting/views.py b/slick_reporting/views.py index 1c76fdc..4383c65 100644 --- a/slick_reporting/views.py +++ b/slick_reporting/views.py @@ -19,7 +19,7 @@ class SampleReportView(FormView): time_series_pattern = '' time_series_columns = None - date_field = 'doc_date' + date_field = None swap_sign = False @@ -222,3 +222,14 @@ def get_initial(self): 'start_date': SLICK_REPORTING_DEFAULT_START_DATE, 'end_date': SLICK_REPORTING_DEFAULT_END_DATE } + + def __init_subclass__(cls) -> None: + date_field = getattr(cls, 'date_field', '') + if not date_field: + raise TypeError(f'`date_field` is not set on {cls}') + + # sanity check, raises error if the columns or date fields is not mapped + cls.report_generator_class.check_columns([cls.date_field], False, cls.report_model) + cls.report_generator_class.check_columns(cls.columns, cls.group_by, cls.report_model) + + super().__init_subclass__() diff --git a/tests/tests.py b/tests/tests.py index 75ca351..4082796 100644 --- a/tests/tests.py +++ b/tests/tests.py @@ -12,6 +12,7 @@ from tests.report_generators import ClientTotalBalance from .models import Client, Product, SimpleSales, OrderLine from slick_reporting.registry import field_registry +from .views import SampleReportView User = get_user_model() SUPER_LOGIN = dict(username='superlogin', password='password') @@ -253,6 +254,17 @@ def test_chart_settings(self): self.assertTrue('pie' in data['chart_settings'][0]['id']) self.assertTrue(data['chart_settings'][0]['title'], 'awesome report title') + def _test_column_names_are_always_strings(self): + # todo + pass + + def test_error_on_missing_date_field(self): + def test_function(): + class TotalClientSales(SampleReportView): + report_model = SimpleSales + + self.assertRaises(TypeError, test_function) + class TestReportFieldRegistry(TestCase): def test_unregister(self): @@ -302,9 +314,9 @@ def register(): def test_creating_a_report_field_on_the_fly(self): from django.db.models import Sum name = BaseReportField.create(Sum, 'value', '__sum_of_value__') - self.assertIn(name, field_registry.get_all_report_fields_names()) + 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 = BaseReportField.create(Sum, 'value') - self.assertIn(name, field_registry.get_all_report_fields_names()) + self.assertNotIn(name, field_registry.get_all_report_fields_names())