Сервер отчетов на django

в 21:29, , рубрики: django, python, reports, метки: ,

Доброго времени суток.

Так случилось, что моя работа связана с написанием отчетов.
Этому я посвятил около 8 лет. Отчеты — это глаза бизнес-процесса и информация,
необходимая для принятия оперативных решений.

Вначале наш отдел делал отчеты,
— Принимая задачи по outlook
— Составляя sql-запрос
— Отправляя результаты заказчику в xls
— В лучшем случае, сохраняя sql-код куда-то в папку (а иногда и не сохраняя)

Но это было скучно и неинтересно. Так появилось простейшее приложение на PHP,
в котором каждый отчет был представлен в виде php-файла с одним классом, имеющим единственный (помимо конструктора) метод show()

В таком виде, система прожила 5,5 лет, за которые мной и еще одним человеком было написано более 500 различных отчетов.
В процессе появился опыт и стало понятно, что многое (если не все) сделано не так, да и PHP уже не устраивал.

Сервер отчетов был переписан на django, где была «админка» и код приложения уже не правился.
В процессе работы снова накопилось несколько мыслей,
в итоге сервер был снова переписан.

Я решил представить сообществу текущую версию системы, возможно, она кому-то облегчит жизнь,
сняв рутину отчетности и переложит ее на машину, которая готова сотни раз формировать отчеты
тысячи людям по сотни разных входных параметров.

Это рабочий инструмент, который вы можете (если захотите) использовать в работе,
который нам использовать просто нравится.

Структура проекта

Все заинтересованные, могут сразу ознакомиться с исходным кодом на https://bitbucket.org

Концепция и основные элементы

Система состоит из набора отчетов.
— Отчеты слабо (практически никак) не связаны друг с другом, чтоб разработчик мог быть уверен, что ничего не испортит в других отчетах, отредактировав какой-то один.
— Элементы отчета жестко связанны внутри отчета и влияют друг на друга.

Отчеты не организованы в иерархию (от этого ушли), а помечаются набором тэгов.
На главной системы отчетов можно щелчками набирать нужные сочетания тэгов, которые работают, как фильт
и поиск отчетов.

Элементы отчета «ленивые» и начинают выполнятся в момент сборки на уровне компоновщика виджетов,
что дает возможность выполнять только те запросы к базе, которые необходимы,
размещая в отчете и группированные данные и детализацию.
За счет кэширования данных на уровне источника, несколько виджетов, выводящих данные из одного источника, дают только один запрос в базу данных.

<!-- 
Выполняются только те источники данных,
на которых основаны виджеты, которые должны быть показаны
-->
{% if get.detail == 'table' %}
    {{table.some_table_detail}}
{% elif get.detail == 'chart' %}
    {{charts.some_chart}}
{% else %}    
    {{tables.grouped_table}}
{% endif %}

Есть параметры доступа пользователю к отчету, помимо самого факта доступности.
Которые могут быть использованы для предоставлении доступа к части отчета или части данных

select * from some_table t
where 1 = 1
-- Это инъекция
{% if user_params.only_filials %}and filial_id in ({{user_params.only_filials|join:","}}){% endif %}
-- Это привязанная переменная, которая заменяется на %s 
{% if user_params.only_sectors %}and sector_id = [[user_params.only_sectors.0]]{% endif %}

При необходимости одного варианта вызова менеджера в шаблонной системе, используется __call__
Если возможны несколько вариантов, используется __getitem__ для словарного объекта.

Каждый отчет может состоять из:

Менеджер окружения

class EnvirementManager(object):
    u'''
    Работа с элементами окружения, render строк внутри переменных окружения,
    разбор запросов и т.п.
    '''

    def __init__(self, **kwargs):

        self._env = kwargs

    def render(self, text, **dict):
        u''' Обработка строки шаблонной системой '''

        return render_string(text, self._dict_mix(dict))
    
    def render_template_compiled(self, template, **dict):
        u''' Обработать предварительно скомпилированный шаблон '''
        
        return template.render(Context(self._dict_mix(dict)))
    
    def render_template(self, path, **dict):
        u''' Обработать '''
        
        return render_to_string(path, 
                                self._dict_mix(dict), 
                                context_instance=RequestContext(self._env['request']))
    
    def render_sql(self, sql, **dict):
        u'''  Обрабатывает строку шаблонной системой
        Возвращает обработанную строку и массив переменных для привязки '''

        sql_rendered = self.render(sql, **dict)
        binds = []

        for bind_token in re.findall(r'[{2}.+]{2}', sql_rendered):
            env_path = bind_token[2:-2]
            binds.append(attribute_by_name(self._env, env_path))
            sql_rendered = sql_rendered.replace(bind_token, u'%s')

        return (sql_rendered, binds)
    
    def _dict_mix(self, dict):
        
        if dict:
            e = self._env.copy()
            e.update(dict)
        else:
            e = self._env
            
        return e

    def get(self, name, value=None):
        
        return self._env.get(name, value)
    
    def __getitem__(self, name):
        
        return self._env[name]

    def __setitem__(self, name, value):
        
        self._env[name] = value

Инициализируется именованными аргументами и используется для рендеринга всего
с учетом тех переменных, которыми инициализирован.

За счет этого, мы можем гарантировать наличие из любого места заранее известного набора переменных.
Может рендерить sql-запросы, которые являются источниками данных (но об этом чуть ниже)

Менеджер источников данных

class DatasetManager(object):
    u''' Управление источниками данных '''

    def __init__(self, request, dataset, env):
        u'''
        Конструктор
        '''

        self._request = request
        self._dataset = dataset
        self._env = env
        self._cache = []
        self._xml = None

        if self._dataset.xml_settings:
            self._xml = etree.fromstring(self._env.render(self._dataset.xml_settings))

    def get_data(self):
        u'''
        Выполняет запрос и возвращает словарь с данными
        '''

        if not self._cache:

            self._cache = [[], []]
            (sql, binds) = self._env.render_sql(self._dataset.sql)
            cursor = self._modify_cursor(connections['reports'].cursor())
            cursor.execute(sql, binds)

            # Настройки колонок запроса
            xml_columns = {}
            if self._xml not in (None, ''):
                for xml_column in self._xml.xpath('/xml/columns/column'):
                    attrs = xml_column.attrib
                    xml_columns[force_unicode(attrs['name'])] = force_unicode(attrs['alias'])

            # Колонки запроса (заменяем название колонки, если запрошено)
            # это может пригодится при использовании БД, запрещаюей использование длинных имен
            for field in cursor.description:
                name_unicode = force_unicode(field.name)
                self._cache[0].append(xml_columns[name_unicode] if name_unicode in xml_columns
                                                                else name_unicode)

            self._cache[1] = cursor.fetchall()

        return self._cache

    def __getitem__(self, name):
        u''' вызовы из шаблонной системы работают через словарные объекты '''

        if name == 'sql':
            return self._env.render(self._dataset.sql)

        elif name == 'render':
            (sql, binds) = self._env.render_sql(self._dataset.sql)
            return {'sql': sql, 'binds': binds}

        elif name == 'data':
            (fields, data) = self.get_data()
            return [dict(zip(fields, row)) for row in data]

    def _modify_cursor(self, cursor):
        u'''
        Модификация параметров курсора (может потредоваться для разных баз баз данных),
        например, для Oracle требуется
        установка параметров локали, отключение number_as_string = True (по умолчанию в стандартном
        backends django)
        '''

        return cursor

Поставляет:
— Данные в виде кортежа (field_names, rows,)
Для визуализации различными типами виджетов, выгрузки в excel
— Данные в виде списка словарей (каждая строка является словарем)
Для прямого обращения к источнику и генерации html, javascript, css кода в менеджере компоновки (о нем немного позже)
— sql-код без обработки переменных привязки
Используется для организации вложенных источников данных, например:

  select key, count(1) from ({{datasets.base_dataset.sql}}) group by key
  

— sql-код и переменные привязки
Просто для отладки и выводе в отчет

За счет этого, можно:
— Менять на лету sql-запрос в зависимости от переменных менеджера окружения
— На основе одного запроса, можно делать другие так, чтоб у вас группировка и детализация никогда не будут отличатся,
так как основаны на одном источнике, и вы никогда не забудите доработать группировку и забыть сделать это в детализации
— Выборку из одного источника данных использовать для динамической сборки другого.
Например, создать заранее неизвестное кол-во колонок в запросе помесячно, по месяцам (или неделям), входящим в отчетный период (задается пользователем в форме, но о них тоже чуть ниже),
и все это независимо от возможностей базы данных (без pivot|unpivot oracle и xml_query oracle)

Менеджер фильтров

class FilterManager(object):
    u''' Класс для управления фильтрами '''
    
    def __init__(self, request, filter_obj, env):
        u''' Конструктор '''
        
        self._request = request
        self._filter_obj = filter_obj
        self._env = env
        self._form_instance = None
        self._changed_data = {}
        
        # Если отправили эту форму, инициализировать форму POST-данными
        # Проверить отправленные данные и, если они отличаются от сохраненных, запомним их в self._changed_data
        if self._request.method == 'POST' and int(self._request.POST['filter_id']) == self._filter_obj.id:
            self._form_instance = self._get_form_class()(self._request.POST)
            if self._form_instance.is_valid():
                data = self._form_instance.cleaned_data
                for key, value in data.items():
                    if self._env['f'].get(key) != value:
                        self._changed_data[key] = value
        
    def get_changed_data(self):
        
        return self._changed_data
    
    def get_form(self):
        u''' Получить экземпляр формы фильтра '''
        
        try:

            # Если экземпляр формы не создан в конструкторе, (при изменениях)
            # значит инициализируем из заранее сохраненных значений
            if self._form_instance is None:
                self._form_instance = self._get_form_class()(initial=self._env['f'])
                
            html = self._env.render('{% load custom_filters %}' + self._filter_obj.html_layout, form=self._form_instance)
            return self._env.render_template('reports_filter.html', form_html=html, filter=self._filter_obj)

        except Exception as e:
            return e
    
    def __call__(self):
        u''' Для вызова в шаблоне '''
        try:
            return self.get_form()
        except Exception as e:
            return e
    
    def _get_form_class(self):
        u''' Собрать форму фильтра и вернуть ее '''
        
        form_attrs = {}
        
        filter_widgets = (DateField, ChoiceField, MultipleChoiceField, BooleanField, CharField, CharField, DateRangeField)
    
        for item in self._filter_obj.form_items.all():
    
            kwargs = {'label': item.title, 'required': False}
            if item.xml_settings:
                xml = xml = etree.fromstring(self._env.render(item.xml_settings))
            else:
                xml = None
    
            if item.widget_type == 0:
                kwargs['widget'] = forms.DateInput(attrs={'class': 'date'})
    
            elif item.widget_type in (1, 2):
                choices = []
                for option in xml.xpath('/xml/options/option'):
                    choices.append((option.attrib['id'], option.attrib['value']))
                sql = xml.xpath('/xml/sql')
                if sql:
                    curs = connections['reports'].cursor().execute(sql[0].text)
                    for row in curs.fetchall():
                        choices.append((row[0], row[1]))
                kwargs['choices'] = choices
    
            elif item.widget_type == 4:
                kwargs['max_length'] = 50
    
            elif item.widget_type == 5:
                default = xml.xpath('/xml/value')
                kwargs['widget'] = forms.HiddenInput(attrs={'value': default[0].text})
    
            form_attrs[item.key] = filter_widgets[item.widget_type](**kwargs)
    
        filter_form = type(str(self._filter_obj.title), (forms.Form,), form_attrs)

        return filter_form

Тут все достаточно тривиально. Собираем форму и отдаем форму и измененные данные

Табличный виджет

class WidgetTableManager(object):
    u''' Управляет работой с табличным виджетом '''

    def __init__(self, request, widget_obj, dataset, env):
        u''' Конструктор '''

        self._request = request
        self._widget_obj = widget_obj
        self._dataset = dataset
        self._env = env
        self._xml = None

        if widget_obj.xml_settings:
            self._xml = etree.fromstring(self._env.render(widget_obj.xml_settings).replace('[[', '{{').replace(']]', '}}'))

    def get_html(self):
        u''' Вернуть html-код таблицы '''

        (fields, data) = self._dataset.get_data()

        field_settings = {}
        table_settings = {}
        if self._xml is not None:

            table_settings_node = xml_node(self._xml, '/xml/table')
            if table_settings_node is not None:
                table_settings = table_settings_node.attrib
            for xml in self._xml.xpath('/xml/fields/field'):
                xml_attributes = dict(xml.attrib)
                field_name = xml_attributes['name']

                if 'lnk' in xml_attributes:
                    xml_attributes['tpl_lnk'] = Template(force_unicode(xml_attributes['lnk']))
                if 'cell_attributes' in xml_attributes:
                    xml_attributes['tpl_cell_attributes'] = Template(force_unicode(xml_attributes['cell_attributes']))

                field_settings[field_name] = xml_attributes

        # Выводимые на экран колонки
        fields_visible = []
        for index, field_name in enumerate(fields):
            settings = field_settings.get(field_name, {})
            if 'display' in settings and settings['display'] == '0':
                continue
            fields_visible.append((index, field_name, settings))

        # Вычислить параметры и привязать к данным
        rows = []
        for row in data:
            row_dict = dict(zip(fields, row))
            row_settings = {}

            # Настройки уровня строки
            if 'field_row_style' in table_settings:
                row_settings['row_style'] = row_dict[table_settings['field_row_style']]
            if 'field_row_attributes' in table_settings:
                row_settings['row_attributes'] = row_dict[table_settings['field_row_attributes']]

            # Перебрать строки и собрать настройки уровня строки
            fields_set = []
            for index, field_name, settings in fields_visible:
                field = {'name': field_name, 'value': row[index]}

                # Если есть ссылка и она должна быть показана
                if 'tpl_lnk' in settings and ('lnk_enable_field' not in settings or row_dict[settings['lnk_enable_field']] not in (0, '0', '', None)):
                    field['lnk'] = self._env.render_template_compiled(settings['tpl_lnk'], row=row_dict)

                # Аттрибуты ячейки
                if 'tpl_cell_attributes' in settings:
                    field['cell_attributes'] = settings['tpl_cell_attributes'].render(Context(row_dict))

                field['settings'] = settings
                fields_set.append(field)

            rows.append({'settings': row_settings, 'fields': fields_set})

        return render_to_string('reports_widget_table.html', {'fields': fields_visible, 'rows': rows, 'widget_obj': self._widget_obj})

    def __call__(self):
        u''' При вызове, возвращает таблицу '''

        try:
            return self.get_html()
        except Exception as e:
            return u'Ошибка в виджете %s: "%s"' % (self._widget_obj.title, e)

Берет данные из источника данных и выводит в табличном виде. Поддерживает
— форматирование
— Генерацию ссылок (которые могут использоваться для детализации ячейки в другой таблице, график или excel)
— Генерацию произвольных аттрибутов строк (для работы javascript, скрытия или показа итоговых строк и т.п.)

Виджет - график

class WidgetChartManager(object):
    u''' Менеджер графика '''

    def __init__(self, request, chart_obj, dataset, env):
        u''' Конструктор '''

        self._request = request
        self._chart_obj = chart_obj
        self._dataset = dataset
        self._env = env
        self._xml = etree.fromstring(self._env.render(self._chart_obj.xml_settings))

        print 1

    def __call__(self):
        u''' При вызове из шаблона '''

        print 2
        try:
            return self.get_chart()
        except Exception as e:
            print unicode(e)
            return unicode(e)

    def get_chart(self):
        u''' html со скриптом сборки графика '''

        (fields, data) = self._dataset.get_data()
        return self._env.render_template('reports_widget_chart.html',
                                         settings=xml_to_dict(xml_node(self._xml, '/xml')),
                                         data=json.dumps([dict(zip(fields, row)) for row in data], cls=JSONEncoder),
                                         chart_obj=self._chart_obj,
                                         )

Тут тоже просто. Берет данные из источника, XML-настройки переводит в dict и рендерит шаблон,
собирая javascript-код графика (используется amcharts)
тэги XML-узлов преобразуются в название парамерта, текст в значение параметра,
то есть можно использовать практически все параметры библиотеки amcharts,
просто поместив нужный тэг у нужную секцию

И, как завершение теоретической части, привожу код класса, который всем этим управляет,
размещая виджеты, или возвращая xls или произвольный документ (html с расширением .doc или .xls)

Табличный виджет

class WidgetTableManager(object):
    u''' Управляет работой с табличным виджетом '''

    def __init__(self, request, widget_obj, dataset, env):
        u''' Конструктор '''

        self._request = request
        self._widget_obj = widget_obj
        self._dataset = dataset
        self._env = env
        self._xml = None

        if widget_obj.xml_settings:
            self._xml = etree.fromstring(self._env.render(widget_obj.xml_settings).replace('[[', '{{').replace(']]', '}}'))

    def get_html(self):
        u''' Вернуть html-код таблицы '''

        (fields, data) = self._dataset.get_data()

        field_settings = {}
        table_settings = {}
        if self._xml is not None:

            table_settings_node = xml_node(self._xml, '/xml/table')
            if table_settings_node is not None:
                table_settings = table_settings_node.attrib
            for xml in self._xml.xpath('/xml/fields/field'):
                xml_attributes = dict(xml.attrib)
                field_name = xml_attributes['name']

                if 'lnk' in xml_attributes:
                    xml_attributes['tpl_lnk'] = Template(force_unicode(xml_attributes['lnk']))
                if 'cell_attributes' in xml_attributes:
                    xml_attributes['tpl_cell_attributes'] = Template(force_unicode(xml_attributes['cell_attributes']))

                field_settings[field_name] = xml_attributes

        # Выводимые на экран колонки
        fields_visible = []
        for index, field_name in enumerate(fields):
            settings = field_settings.get(field_name, {})
            if 'display' in settings and settings['display'] == '0':
                continue
            fields_visible.append((index, field_name, settings))

        # Вычислить параметры и привязать к данным
        rows = []
        for row in data:
            row_dict = dict(zip(fields, row))
            row_settings = {}

            # Настройки уровня строки
            if 'field_row_style' in table_settings:
                row_settings['row_style'] = row_dict[table_settings['field_row_style']]
            if 'field_row_attributes' in table_settings:
                row_settings['row_attributes'] = row_dict[table_settings['field_row_attributes']]

            # Перебрать строки и собрать настройки уровня строки
            fields_set = []
            for index, field_name, settings in fields_visible:
                field = {'name': field_name, 'value': row[index]}

                # Если есть ссылка и она должна быть показана
                if 'tpl_lnk' in settings and ('lnk_enable_field' not in settings or row_dict[settings['lnk_enable_field']] not in (0, '0', '', None)):
                    field['lnk'] = self._env.render_template_compiled(settings['tpl_lnk'], row=row_dict)

                # Аттрибуты ячейки
                if 'tpl_cell_attributes' in settings:
                    field['cell_attributes'] = settings['tpl_cell_attributes'].render(Context(row_dict))

                field['settings'] = settings
                fields_set.append(field)

            rows.append({'settings': row_settings, 'fields': fields_set})

        return render_to_string('reports_widget_table.html', {'fields': fields_visible, 'rows': rows, 'widget_obj': self._widget_obj})

    def __call__(self):
        u''' При вызове, возвращает таблицу '''

        try:
            return self.get_html()
        except Exception as e:
            return u'Ошибка в виджете %s: "%s"' % (self._widget_obj.title, e)

Берет данные из источника данных и выводит в табличном виде. Поддерживает
— форматирование
— Генерацию ссылок (которые могут использоваться для детализации ячейки в другой таблице, график или excel)
— Генерацию произвольных аттрибутов строк (для работы javascript, скрытия или показа итоговых строк и т.п.)

Менеджер отчетов

class ReportManager(object):
    u''' Управляет отчетами '''

    def __init__(self, request, report):

        self._report = report
        self._request = request
        self._user = request.user
        self._forms = {}
        self._env = EnvirementManager(request=request, user_params=self._get_user_params(), forms={}, f={})
        self._datasets = {}
        self._widgets_table = {}
        self._widgets_chart = {}

        self._load_stored_filter_values()

        # Сборка фильтров
        for filter_obj in self._report.forms.only('title', 'html_layout'):
            filter_manager = FilterManager(self._request, filter_obj, self._env)
            self._save_stored_filter_values(filter_manager.get_changed_data())
            self._forms[filter_obj.title] = filter_manager
        self._env['forms'] = self._forms

        # Собираем источники данных
        for ds in self._report.datasets.only('sql', 'title', 'xml_settings'):
            self._datasets[ds.title] = DatasetManager(request, ds, self._env)
        self._env['datasets'] = self._datasets

        # Собираем виджет-таблицы
        for widget_obj in self._report.widgets_table.only('title', 'dataset', 'table_header', 'xml_settings'):
            self._widgets_table[widget_obj.title] = WidgetTableManager(self._request,
                                                                       widget_obj,
                                                                       self._datasets[widget_obj.dataset.title],
                                                                       self._env)
        self._env['tables'] = self._widgets_table

        # Виджеты - графики
        for chart_obj in self._report.widgets_chart.only('title', 'dataset', 'xml_settings'):
            self._widgets_chart[chart_obj.title] = WidgetChartManager(self._request,
                                                                      chart_obj,
                                                                      self._datasets[chart_obj.dataset.title],
                                                                      self._env)
        self._env['charts'] = self._widgets_chart

    def get_request(self):
        u''' Результат отчета '''

        response_type = self._request.REQUEST.get('response_type', 'html')

        if response_type == 'xls':
            return self._get_request_xls(self._request.REQUEST['xls'])
        elif response_type == 'template':
            return self._get_request_template(self._request.REQUEST['template'])
        else:
            return self._get_request_html()

    def _get_request_html(self):
        u''' Вернуть результат в виде html для вывода на экран '''

        context = {'favorite_reports': self._user.reports_favorited.all()}
        context['report_body'] = self._env.render(self._report.html_layout)
        context['breadcrumbs'] = (('Отчеты', reverse('reports_home')), (self._report.title, None))
        context['filter_presets'] = self._report.filter_presets.filter(user=self._user)
        context['report'] = self._report

        return render(self._request, 'reports_report.html', context)

    def _get_request_template(self):
        u''' Вернуть результат в виде html для вывода на экран '''

        # TODO
        raise NotImplementedError(u'Не реализовано')

    def _get_request_xls(self, dataset_title):
        u""" Вернуть результат выгрузки в xls """

        dataset = self._datasets[dataset_title]
        (columns, data) = dataset.get_data()
        w = Workbook(optimized_write=True)
        sheet = w.create_sheet(0)
        sheet.append(columns)

        rows_in_sheet = 0
        for row in data:

            if rows_in_sheet > 1000000:
                sheet = w.create_sheet()
                sheet.append(columns)
                rows_in_sheet = 0

            sheet.append(row)
            rows_in_sheet += 1

        try:
            tmpFileName = os.tempnam()
            w.save(tmpFileName)
            fh = open(tmpFileName, 'rb')
            resp = HttpResponse(fh.read(), 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
        finally:
            if fh:
                fh.close()
            os.unlink(tmpFileName)

        resp['Content-Disposition'] = 'attachment; filename="Выгрузка.xlsx"'
        return resp

    def _get_request_document_template(self, template_title):
        u""" Вернуть ответ, сгенерировав документ по шаблону """

        pass

    def _save_stored_filter_values(self, values):
        u"""
        Записать значения фильтров в базу и в _env
        Если результат не html, то в базу ничего не сохраняем,
        потому что не происходит перезагрузки страницы и не нужно восстанавливать значения фильтров
        """

        for key, value in values.items():
            self._env['f'][key] = value
            if values.get('response_type', 'html') == 'html':
                (store_item, is_created) = models.WidgetFormStorage.objects.get_or_create(user=self._user, report=self._report, key=key)
                store_item.value = pickle.dumps(value)
                store_item.save()

    def _load_stored_filter_values(self):
        u""" Загрузить значения форм, считав их из базы данных """

        for item in self._report.form_storage.all():
            self._env['f'][item.key] = pickle.loads(item.value)

    def _get_user_params(self):
        u""" Вернуть параметры пользователя """

        params = {}

        try:

            param_string = models.UserAccess.objects.get(report=self._report, user=self._user).params
            if param_string:
                for pair in param_string.split(';'):
                    (key, values_str) = pair.split('=')
                    values = values_str.split(',')
                    params[key] = values

        except Exception, e:
            pass

        return params

Имеет только один публичный метод, который возвращает httpResponse (html, вложение или xlsx)

Вот, в общем-то и все.
Интерфейс администратора описывать не стал.

css от bootstrap

Немного практики и картинок

Источник данных ds

select key, value, value * 0.5 as value1 from (
    select 1 as key, 2000 as value union all
    select 2 as key, 4000 as value union all
    select 3 as key, 6000 as value union all
    select 4 as key, 3000 as value union all
    select 5 as key, 2000 as value union all
    select 6 as key, 1000 as value
) t
{% if f.doble_rows %}cross join (select 1 union all select 2) t1 {% endif %}

Источник данных ds1

select 
t.key as key,
t.value as value,
case when key = 1 then 'background-color: #dff0d8;' end as row_style_field,
case when key = 2 then 'class="error"' end as row_attribute_field
{% if f.add_column %}
    {% for row in datasets.ds.data %}
    , {{row.key}} as Поле{{row.key}} 
    {% endfor %}
{% endif %}
from ({{datasets.ds.sql}}) t
    {% if request.GET.key %} where key = [[request.GET.key]] {% endif %}
    {% if f.limit %} limit [[f.limit]] {% endif %}

Табличный виджет t:
Создадим, выбрав из списка источник ds1
и прописав название. Все остальное не трогаем.

Таблица t1 (на ds1)

<xml>
    <table field_row_style='row_style_field' field_row_attributes='row_attribute_field'/>
    <fields>
        <field name='Ключ' classes='text-right'/>
        <field name='value' classes='text-right' lnk='?response_type=xls&xls=ds&key=[[row.Ключ]]&from=value'/>
        {% for row in datasets.ds.data %}
        <field name='Поле{{row.key}}' classes='text-right' lnk='?response_type=xls&xls=ds1&key=[[row.Ключ]]&from=Поле{{row.key}}'/>
        {% endfor %}
        <field name='Поле1' display='0'/>
        <field name='row_style_field' display='0'/>
        <field name='row_attribute_field' display='0'/>
    </fields>
</xml>

Таблица t1 (на ds1)

<xml>
    <table field_row_style='row_style_field' field_row_attributes='row_attribute_field'/>
    <fields>
        <field name='Ключ' classes='text-right'/>
        <field name='value' classes='text-right' lnk='?response_type=xls&xls=ds&key=[[row.Ключ]]&from=value'/>
        {% for row in datasets.ds.data %}
        <field name='Поле{{row.key}}' classes='text-right' lnk='?response_type=xls&xls=ds1&key=[[row.Ключ]]&from=Поле{{row.key}}'/>
        {% endfor %}
        <field name='Поле1' display='0'/>
        <field name='row_style_field' display='0'/>
        <field name='row_attribute_field' display='0'/>
    </fields>
</xml>

График test (на ds)

<xml>

    <chart>
        <categoryField>key</categoryField>
        <marginTop>32</marginTop>
    </chart>

    <categoryAxis>
        <labelsEnabled>true</labelsEnabled>
        <gridCount>50</gridCount>
        <equalSpacing>true</equalSpacing>
    </categoryAxis>

    <valueAxis>
        <valueAxisLeft>
            <stackType>regular</stackType> 
            <gridAlpha>0.07</gridAlpha>
        </valueAxisLeft>
    </valueAxis>

    <cursor>
        <bulletsEnabled>true</bulletsEnabled>
    </cursor>

    <graphs>
        <graph>
            <type>column</type>
            <title>Отказы</title>
            <valueField>value</valueField>
            <balloonText>[[category]] дней: [[value]] шт.</balloonText>
            <lineAlpha>0</lineAlpha>
            <fillAlphas>0.6</fillAlphas>
        </graph>
        <graph1>
            <type>column</type>
            <title>Не отказы</title>
            <valueField>value1</valueField>
            <balloonText>[[category]] дней: [[value]] шт.</balloonText>
            <lineAlpha>0</lineAlpha>
            <fillAlphas>0.6</fillAlphas>
        </graph1>
    </graphs>

</xml>

График test1 (на ds)

<xml>

    <chart>
        <categoryField>key</categoryField>
        <marginTop>32</marginTop>
    </chart>

    <categoryAxis>
        <labelsEnabled>true</labelsEnabled>
        <gridCount>50</gridCount>
        <equalSpacing>true</equalSpacing>
    </categoryAxis>

    <valueAxis>
        <valueAxisLeft>
            <gridAlpha>0.07</gridAlpha>
        </valueAxisLeft>
    </valueAxis>

    <cursor>
        <bulletsEnabled>true</bulletsEnabled>
    </cursor>

    <graphs>
        <graph>
            <type>column</type>
            <title>Отказы</title>
            <valueField>value</valueField>
            <balloonText>[[category]] дней: [[value]] шт.</balloonText>
            <lineAlpha>0</lineAlpha>
            <fillAlphas>0.6</fillAlphas>
        </graph>
        <graph1>
            <type>column</type>
            <title>Не отказы</title>
            <valueField>value1</valueField>
            <balloonText>[[category]] дней: [[value]] шт.</balloonText>
            <lineAlpha>0</lineAlpha>
            <fillAlphas>0.6</fillAlphas>
        </graph1>
        <graph2>
            <type>smoothedLine</type>
            <title>Не отказы</title>
            <valueField>value1</valueField>
        </graph2>
    </graphs>

</xml>

Размещаем на экране

<h2>Работа с источниками данных</h2>
<div class='well well-small'>{{forms.f}}</div>

<h3>После пре-процессора django template</h3>
<pre>
sql:{{datasets.ds1.render.sql}}
параметры:{{datasets.ds1.render.binds}}
</pre>

<h3>После выполнения</h3>
{{tables.t}}
<a class='btn btn-success' href='?response_type=xls&xls=ds1'>В Excel</a>

<h2>Визуализация данных</h2>
{{tables.t1}}
<div style='height:500px;width:500px;'>{{charts.test}}</div>
<div style='height:500px;width:500px;'>{{charts.test1}}</div>

Админка выглядит так

Сервер отчетов на django
Вот что получилось

Список отчетов:
Сервер отчетов на django

Наш отчет:
Сервер отчетов на django

Хочу сказать спасибо за помощь Павлу, Александру, Евгению.
Спасибо за внимание. Если хотите, можете взять это из репозитория и использовать по своему усмотрению.
bitbucket.org/dibrovsd/py_docflow/

P.S. в репозитории есть еще практически законченное приложение документооборота,
но о нем немного позже, если вам это будет интересно.

P.P.S.
Наверняка найдется множество не оптимальных решений, с python и django я знаком недавно.
Все конструктивные предложения прошу писать в «личку».

Автор: dibrovsd

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js