Add widgets and dashboards.

This commit is contained in:
Albert Cervera i Areny 2023-01-27 13:37:00 +01:00
parent e524ff3eb7
commit f0762083f1
23 changed files with 1201 additions and 126 deletions

View File

@ -7,6 +7,8 @@ from . import babi
from . import test_model
from . import report
from . import table
from . import dashboard
from . import action
def register():
Pool.register(
@ -32,6 +34,14 @@ def register():
babi.UpdateDataWizardStart,
babi.UpdateDataWizardUpdated,
babi.CleanExecutionsStart,
dashboard.Dashboard,
dashboard.DashboardItem,
dashboard.Widget,
dashboard.WidgetParameter,
action.View,
action.Action,
action.ActionDashboard,
action.Menu,
test_model.TestBabiModel,
table.Table,
table.Field,

67
action.py Normal file
View File

@ -0,0 +1,67 @@
from lxml import etree
from trytond.pool import PoolMeta, Pool
from trytond.model import fields, ModelSQL, ModelView
from trytond.ir.action import ActionMixin
class View(metaclass=PoolMeta):
__name__ = 'ir.ui.view'
@classmethod
def get_rng(cls, type_):
rng = super().get_rng(type_)
if type_ in ('form', 'list-form'):
widgets = rng.xpath(
'//ns:define/ns:optional/ns:attribute'
'/ns:name[.="widget"]/following-sibling::ns:choice',
namespaces={'ns': 'http://relaxng.org/ns/structure/1.0'})[0]
subelem = etree.SubElement(widgets,
'{http://relaxng.org/ns/structure/1.0}value')
subelem.text = 'chart'
return rng
class Action(metaclass=PoolMeta):
__name__ = 'ir.action'
@classmethod
def get_action_values(self, type_, action_ids, columns=None):
pool = Pool()
ActionDashboard = pool.get('babi.action.dashboard')
actions = super().get_action_values(type_, action_ids, columns)
if type_ == 'babi.action.dashboard':
boards = {x.id: x for x in ActionDashboard.browse(action_ids)}
for values in actions:
values['dashboard'] = boards[values['id']].dashboard.id
return actions
class ActionDashboard(ActionMixin, ModelSQL, ModelView):
"Action Act Dashboard"
__name__ = 'babi.action.dashboard'
dashboard = fields.Many2One('babi.dashboard', 'Dashboard', required=True,
ondelete='CASCADE')
action = fields.Many2One('ir.action', 'Action', ondelete='CASCADE')
@staticmethod
def default_type():
return 'babi.action.dashboard'
class Menu(metaclass=PoolMeta):
__name__ = 'ir.ui.menu'
@classmethod
def __setup__(cls):
super().__setup__()
cls.action.selection.append(('babi.action.dashboard', 'Dashboard'))
@classmethod
def _get_action(cls, action_id):
pool = Pool()
Action = pool.get('ir.action')
action = Action(action_id)
if action.type == 'babi.action.dashboard':
action = ActionDashboard(action_id)
return super()._get_action(action)

36
action.xml Normal file
View File

@ -0,0 +1,36 @@
<tryton>
<data>
<!-- babi.action.dashboard -->
<!---->
<record model="ir.ui.view" id="action_dashboard_view_form">
<field name="model">babi.action.dashboard</field>
<field name="type">form</field>
<field name="name">action_dashboard_form</field>
</record>
<record model="ir.ui.view" id="action_dashboard_view_tree">
<field name="model">babi.action.dashboard</field>
<field name="type">tree</field>
<field name="name">action_dashboard_list</field>
</record>
<record model="ir.action.act_window" id="act_action_dashboard_form">
<field name="name">Dashboards</field>
<field name="type">ir.action.act_window</field>
<field name="res_model">babi.action.dashboard</field>
</record>
<record model="ir.action.act_window.view" id="act_action_url_form_view1">
<field name="sequence" eval="1"/>
<field name="view" ref="action_dashboard_view_tree"/>
<field name="act_window" ref="act_action_dashboard_form"/>
</record>
<record model="ir.action.act_window.view" id="act_action_url_form_view2">
<field name="sequence" eval="2"/>
<field name="view" ref="action_dashboard_view_form"/>
<field name="act_window" ref="act_action_dashboard_form"/>
</record>
<menuitem
parent="ir.menu_action"
action="act_action_dashboard_form"
sequence="60"
id="menu_action_dashboard"/>
</data>
</tryton>

View File

@ -672,51 +672,37 @@ contains the full copyright notices and license terms. -->
</record>
<!-- Menus -->
<menuitem id="menu_babi" name="Business Intelligence" sequence="100"
icon="tryton-graph"/>
<menuitem id="menu_configuration" name="Configuration"
parent="menu_babi" sequence="10" icon="tryton-settings"/>
<menuitem id="menu_update_data" name="Update data"
parent="menu_babi" sequence="10" active="0"/>
<menuitem id="menu_historical_data" name="View historical data"
parent="menu_babi" sequence="10" active="0"/>
<menuitem id="menu_babi" name="Business Intelligence" sequence="100" icon="tryton-graph"/>
<menuitem id="menu_configuration" name="Configuration" parent="menu_babi" sequence="10" icon="tryton-settings"/>
<menuitem id="menu_update_data" name="Update data" parent="menu_babi" sequence="10" active="0"/>
<menuitem id="menu_historical_data" name="View historical data" parent="menu_babi" sequence="10" active="0"/>
<record model="ir.ui.menu-res.group"
id="menu_babi_group_babi">
<record model="ir.ui.menu-res.group" id="menu_babi_group_babi">
<field name="menu" ref="menu_babi"/>
<field name="group" ref="group_babi"/>
</record>
<record model="ir.ui.menu-res.group"
id="menu_configuration_group_babi_admin">
<record model="ir.ui.menu-res.group" id="menu_configuration_group_babi_admin">
<field name="menu" ref="menu_configuration"/>
<field name="group" ref="group_babi_admin"/>
</record>
<menuitem action="act_babi_expression" id="menu_babi_expression"
parent="menu_configuration" sequence="10" name="Expressions"/>
<menuitem action="act_babi_filter" id="menu_babi_filter"
parent="menu_configuration" sequence="20" name="Filters"/>
<menuitem action="act_babi_report" id="menu_babi_report"
parent="menu_babi" sequence="20" name="Reports"/>
<record model="ir.ui.menu-res.group"
id="menu_report_group_babi">
<menuitem action="act_babi_expression" id="menu_babi_expression" parent="menu_configuration" sequence="10" name="Expressions"/>
<menuitem action="act_babi_filter" id="menu_babi_filter" parent="menu_configuration" sequence="20" name="Filters"/>
<menuitem action="act_babi_report" id="menu_babi_report" parent="menu_babi" sequence="20" name="Reports"/>
<record model="ir.ui.menu-res.group" id="menu_report_group_babi">
<field name="menu" ref="menu_babi_report"/>
<field name="group" ref="group_babi"/>
</record>
<record model="ir.ui.menu-res.group"
id="menu_report_group_babi_admin">
<record model="ir.ui.menu-res.group" id="menu_report_group_babi_admin">
<field name="menu" ref="menu_babi_report"/>
<field name="group" ref="group_babi_admin"/>
</record>
<menuitem action="act_babi_execution" id="menu_babi_execution"
parent="menu_babi_report" sequence="10" name="Executions"/>
<menuitem action="clear_executions_wizard"
id="menu_babi_clean_execution" parent="menu_babi_report"
sequence="20" name="Clean Executions"/>
<menuitem action="act_babi_execution" id="menu_babi_execution" parent="menu_babi_report" sequence="10" name="Executions"/>
<menuitem action="clear_executions_wizard" id="menu_babi_clean_execution" parent="menu_babi_report" sequence="20" name="Clean Executions"/>
<record model="ir.ui.menu-res.group"
id="menu_execution_group_babi_admin">
<record model="ir.ui.menu-res.group" id="menu_execution_group_babi_admin">
<field name="menu" ref="menu_babi_execution"/>
<field name="group" ref="group_babi_admin"/>
</record>

536
dashboard.py Normal file
View File

@ -0,0 +1,536 @@
import json
from trytond.pool import Pool, PoolMeta
from trytond.backend import DatabaseOperationalError
from trytond.model import ModelSQL, ModelView, sequence_ordered, fields
from trytond.pyson import Eval, Bool
from trytond.transaction import Transaction
from trytond.exceptions import UserError
from trytond.i18n import gettext
class Dashboard(ModelSQL, ModelView):
'Dashboard'
__name__ = 'babi.dashboard'
name = fields.Char('Name', required=True)
widgets = fields.One2Many('babi.dashboard.item', 'dashboard', 'Widgets')
view = fields.Function(fields.Text('View'), 'get_view')
@classmethod
def __setup__(cls):
super().__setup__()
cls._buttons.update({
'show': {
'icon': 'tryton-board',
},
})
def get_view(self, name):
pool = Pool()
Widget = pool.get('babi.dashboard.item')
return json.dumps(Widget._get_view(self.widgets))
@classmethod
@ModelView.button
def show(cls, actions):
if not actions:
return
action = actions[0]
return {
'name': action.name,
'type': 'babi.action.dashboard',
'dashboard': action.id,
}
class DashboardItem(sequence_ordered(), ModelSQL, ModelView):
'Dashboard Item'
__name__ = 'babi.dashboard.item'
dashboard = fields.Many2One('babi.dashboard', 'Dashboard', required=True)
widget = fields.Many2One('babi.widget', 'Widget', required=True)
colspan = fields.Integer('Columns')
parent = fields.Many2One('babi.dashboard.item', 'Parent', domain=[
('dashboard', '=', Eval('dashboard')),
], depends=['dashboard'])
children = fields.One2Many('babi.dashboard.item', 'parent', 'Children')
@classmethod
def __setup__(cls):
super().__setup__()
cls.__access__.add('dashboard')
@classmethod
def _get_view(cls, widgets):
res = []
for widget in widgets:
res.append({
'widget': widget.widget.id,
'colspan': widget.colspan or 1,
'children': cls._get_view(widget.children),
})
return res
class Widget(ModelSQL, ModelView):
'Widget'
__name__ = 'babi.widget'
name = fields.Char('Name', required=True)
type = fields.Selection([
(None, ''),
('area', 'Area'),
('bar', 'Bar'),
('bubble', 'Bubble'),
('doughnut', 'Doughnut'),
('funnel', 'Funnel'),
('gauge', 'Gauge'),
('line', 'Line'),
('pie', 'Pie'),
('scatter', 'Scatter'),
('table', 'Table'),
('value', 'Value'),
], 'Type')
table = fields.Many2One('babi.table', 'Table', states={
'invisible': ~Bool(Eval('type')),
'required': Bool(Eval('type')),
}, depends=['type'])
where = fields.Char('Where', states={
'invisible': ~Bool(Eval('type')),
}, depends=['type'])
parameters = fields.One2Many('babi.widget.parameter', 'widget',
'Parameters')
chart = fields.Function(fields.Text('Chart'), 'on_change_with_chart')
timeout = fields.Integer('Timeout (s)', required=True)
show_legend = fields.Boolean('Show Legend')
@staticmethod
def default_timeout():
Config = Pool().get('babi.configuration')
config = Config(1)
return config.default_timeout or 30
@fields.depends('type', 'where', 'parameters', 'timeout', 'show_legend',
methods=['get_values'])
def on_change_with_chart(self, name=None):
data = []
layout = {
# None should become false, not null in JS
'showlegend': bool(self.show_legend),
}
config = {}
try:
chart = {
'type': self.type,
}
data.append(chart)
if self.type == 'area':
chart.update(self.get_values())
chart['type'] = 'scatter'
chart['fill'] = 'tonexty'
elif self.type == 'bar':
chart.update(self.get_values())
elif self.type == 'pie':
chart.update(self.get_values())
elif self.type == 'bubble':
values = self.get_values()
chart['type'] = 'scatter'
chart.update({
'x': values.get('x', []),
'y': values.get('y', []),
})
chart['mode'] = 'markers'
chart['marker'] = {
'size': values.get('sizes', []),
}
elif self.type == 'doughnut':
chart.update(self.get_values())
chart['type'] = 'pie'
chart['hole'] = 0.4
elif self.type == 'funnel':
values = self.get_values()
chart['type'] = 'funnelarea'
chart['values'] = values.get('values', [])
chart['text'] = values.get('labels', [])
layout.update({
'funnelmode': 'stack',
})
elif self.type == 'gauge':
values = self.get_values()
value = values.get('value', [])
chart['value'] = value and value[0] or '-'
chart['mode'] = 'gauge'
if 'delta' in values:
chart['mode'] += '+delta'
delta = values['delta']
chart['delta'] = {
'reference': delta and delta[0] or '-',
}
min = values.get('min', [])
min = min and min[0] or 0
max = values.get('max', [])
max = max and max[0] or 100
chart['type'] = 'indicator'
chart['gauge'] = {
'axis': {
'visible': False,
'range': [min, max],
},
}
print(chart)
elif self.type == 'line':
chart['type'] = 'scatter'
chart.update(self.get_values())
elif self.type == 'scatter':
chart['type'] = 'scatter'
chart.update(self.get_values())
chart['mode'] = 'markers'
elif self.type == 'table':
values = self.get_values()
chart['type'] = 'table'
header = []
columns = []
for key, vals in self.get_values().items():
header.append(key)
columns.append(vals)
chart['header'] = {
'values': header,
}
chart['cells'] = {
'values': columns,
}
elif self.type == 'value':
values = self.get_values()
value = values.get('value', [])
chart['value'] = value and value[0] or '-'
chart['mode'] = 'number'
if 'delta' in values:
chart['mode'] += '+delta'
delta = values['delta']
chart['delta'] = {
'reference': delta and delta[0] or '-',
}
chart['type'] = 'indicator'
chart['gauge'] = {
'axis': {
'visible': False,
},
}
except Exception as e:
data = [{
'type': 'error',
'message': str(e),
}]
return json.dumps({
'data': data,
'layout': layout,
'config': config,
})
@fields.depends('type', 'table', 'parameters')
def on_change_type(self):
pool = Pool()
Parameter = pool.get('babi.widget.parameter')
if not self.type:
return
settings = self.parameter_settings()
parameters = []
for type in settings.keys():
for parameter in self.parameters:
if parameter.type == type:
parameters.append(parameter)
break
else:
parameter = Parameter()
parameter.type = type
parameters.append(parameter)
self.parameters = tuple(parameters)
def get_parameter(self, type):
for parameter in self.parameters:
if parameter.type == type:
return parameter
@fields.depends('table', 'parameters', 'where', 'timeout')
def get_values(self):
if not self.table:
return {}
fields = [x.select_expression for x in self.parameters]
if None in fields:
return {}
groupby = [x.groupby_expression for x in self.parameters
if x.groupby_expression]
records = self.table.execute_query(fields, self.where, groupby,
self.timeout)
res = {}
types = [x.type for x in self.parameters]
# Transpose records and fields
transposed = list(map(list, zip(*records)))
for type, values in zip(types, transposed):
res[type] = values
return res
@fields.depends('type')
def parameter_settings(self):
if self.type == 'area':
return {
'x': {
'min': 1,
'max': 1,
'aggregate': 'forbidden',
},
'y': {
'min': 1,
'max': 10,
'aggregate': 'required',
},
}
elif self.type == 'bar':
return {
'x': {
'min': 1,
'max': 1,
'aggregate': 'forbidden',
},
'y': {
'min': 1,
'max': 10,
'aggregate': 'required',
},
}
elif self.type == 'pie':
return {
'labels': {
'min': 1,
'max': 1,
'aggregate': 'forbidden',
},
'values': {
'min': 1,
'max': 1,
'aggregate': 'required',
},
}
elif self.type == 'bubble':
return {
'x': {
'min': 1,
'max': 1,
'aggregate': 'forbidden',
},
'y': {
'min': 1,
'max': 1,
'aggregate': 'forbidden',
},
'sizes': {
'min': 1,
'max': 1,
'aggregate': 'required',
},
}
elif self.type == 'doughnut':
return {
'labels': {
'min': 1,
'max': 1,
'aggregate': 'forbidden',
},
'values': {
'min': 1,
'max': 1,
'aggregate': 'required',
},
}
elif self.type == 'funnel':
return {
'labels': {
'min': 1,
'max': 1,
'aggregate': 'forbidden',
},
'values': {
'min': 1,
'max': 1,
'aggregate': 'required',
},
}
elif self.type == 'gauge':
return {
'delta': {
'min': 0,
'max': 1,
'aggregate': 'required',
},
'minimum': {
'min': 1,
'max': 1,
'aggregate': 'required',
},
'maximum': {
'min': 1,
'max': 1,
'aggregate': 'required',
},
'value': {
'min': 1,
'max': 1,
'aggregate': 'required',
},
}
elif self.type == 'line':
return {
'x': {
'min': 1,
'max': 1,
'aggregate': 'forbidden',
},
'y': {
'min': 1,
'max': 10,
'aggregate': 'required',
},
}
elif self.type == 'scatter':
return {
'x': {
'min': 1,
'max': 1,
'aggregate': 'forbidden',
},
'y': {
'min': 1,
'max': 1,
'aggregate': 'forbidden',
},
}
elif self.type == 'table':
return {
'labels': {
'min': 1,
'max': 1,
'aggregate': 'forbidden',
},
'values': {
'min': 1,
'max': 10,
'aggregate': 'required',
},
}
elif self.type == 'value':
return {
'delta': {
'min': 0,
'max': 1,
'aggregate': 'required',
},
'value': {
'min': 1,
'max': 1,
'aggregate': 'required',
},
}
class WidgetParameter(ModelSQL, ModelView):
'Widget Parameter'
__name__ = 'babi.widget.parameter'
widget = fields.Many2One('babi.widget', 'Widget', required=True,
ondelete='CASCADE')
type = fields.Selection([
('delta', 'Delta'),
('labels', 'Labels'),
('minimum', 'Minimum'),
('maximum', 'Maximum'),
('sizes', 'Sizes'),
('value', 'Value'),
('values', 'Values'),
('x', 'X'),
('y', 'Y'),
], 'Type', required=True)
field = fields.Many2One('babi.field', 'Field', domain=[
('table', '=', Eval('_parent_widget', {}).get('table', -1)),
])
aggregate = fields.Selection([
(None, ''),
('sum', 'Sum'),
('count', 'Count'),
('avg', 'Average'),
('min', 'Minimum'),
('max', 'Maximum'),
], 'Aggregate', states={
'required': Bool(Eval('aggregate_required')),
'invisible': Bool(Eval('aggregate_invisible')),
}, depends=['type', 'aggregate_required', 'aggregate_invisible'])
aggregate_required = fields.Function(fields.Boolean('Aggregate Required'),
'on_change_with_aggregate_required')
aggregate_invisible = fields.Function(fields.Boolean('Aggregate Invisible'),
'on_change_with_aggregate_invisible')
@fields.depends('type', 'widget', '_parent_widget.type')
def on_change_with_aggregate_required(self, name=None):
if not self.widget:
return
settings = self.widget.parameter_settings()
return settings.get(self.type, {}).get('aggregate') == 'required'
@fields.depends('type', 'widget', '_parent_widget.type')
def on_change_with_aggregate_invisible(self, name=None):
if not self.widget:
return
settings = self.widget.parameter_settings()
return settings.get(self.type, {}).get('aggregate') == 'forbidden'
@classmethod
def __setup__(cls):
super().__setup__()
cls.__access__.add('widget')
@classmethod
def validate(cls, parameters):
super().validate(parameters)
for parameter in parameters:
parameter.check_aggregate()
parameter.check_type()
def get_rec_name(self, name):
res = self.type
if self.field:
res += ' - ' + self.field.name
return res
def check_aggregate(self):
if not self.aggregate or not self.field:
return
if self.aggregate in ('sum', 'avg'):
if self.field.type not in ('integer', 'float', 'numeric'):
raise UserError(gettext('babi.msg_invalid_aggregate',
parameter=self.rec_name, widget=self.widget.rec_name))
settings = self.widget.parameter_settings()
def check_type(self):
settings = self.widget.parameter_settings()
if self.type not in settings:
raise UserError(gettext('babi.msg_invalid_parameter_type',
parameter=self.rec_name, widget=self.widget.rec_name,
types=', '.join(settings.keys())))
@property
def groupby_expression(self):
if not self.field:
return
if self.aggregate:
return
return self.field.internal_name
@property
def select_expression(self):
if not self.field:
return
if self.aggregate:
return '%s(%s)' % (self.aggregate.upper(), self.field.internal_name)
else:
settings = self.widget.parameter_settings()
if settings.get(self.type, {}).get('aggregate') == 'required':
return
return self.field.internal_name

171
dashboard.xml Normal file
View File

@ -0,0 +1,171 @@
<tryton>
<data>
<record model="ir.action.url" id="action_url">
<field name="name">Dashboard</field>
<field name="url">babi-board</field>
</record>
<menuitem id="menu_dashboard_open" name="Dashboard" parent="menu_babi" sequence="40" action="action_url"/>
<!-- babi.dashboard -->
<record model="ir.ui.view" id="babi_dashboard_form_view">
<field name="model">babi.dashboard</field>
<field name="type">form</field>
<field name="name">dashboard_form</field>
</record>
<record model="ir.ui.view" id="babi_dashboard_tree_view">
<field name="model">babi.dashboard</field>
<field name="type">tree</field>
<field name="name">dashboard_list</field>
</record>
<record model="ir.action.act_window" id="act_babi_dashboard">
<field name="name">Dashboards</field>
<field name="res_model">babi.dashboard</field>
</record>
<record model="ir.action.act_window.view" id="act_babi_dashboard_view1">
<field name="sequence" eval="10"/>
<field name="view" ref="babi_dashboard_tree_view"/>
<field name="act_window" ref="act_babi_dashboard"/>
</record>
<record model="ir.action.act_window.view" id="act_babi_dashboard_view2">
<field name="sequence" eval="20"/>
<field name="view" ref="babi_dashboard_form_view"/>
<field name="act_window" ref="act_babi_dashboard"/>
</record>
<record model="ir.model.access" id="access_babi_dashboard">
<field name="model" search="[('model', '=', 'babi.dashboard')]"/>
<field name="perm_read" eval="False"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_delete" eval="False"/>
</record>
<record model="ir.model.access" id="access_babi_dashboard_babi">
<field name="model" search="[('model', '=', 'babi.dashboard')]"/>
<field name="group" ref="group_babi"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_delete" eval="True"/>
</record>
<record model="ir.model.button" id="show_dashboard_button">
<field name="name">show</field>
<field name="string">Show</field>
<field name="model" search="[('model', '=', 'babi.dashboard')]"/>
</record>
<record model="ir.model.button-res.group" id="show_dashboard_button_group_babi">
<field name="button" ref="show_dashboard_button"/>
<field name="group" ref="group_babi"/>
</record>
<menuitem id="menu_dashboard" parent="menu_babi" sequence="1" action="act_babi_dashboard"/>
<!-- babi.dashboard.item -->
<record model="ir.ui.view" id="babi_dashboard_item_form_view">
<field name="model">babi.dashboard.item</field>
<field name="type">form</field>
<field name="name">dashboard_item_form</field>
</record>
<record model="ir.ui.view" id="babi_dashboard_item_tree_view">
<field name="model">babi.dashboard.item</field>
<field name="type">tree</field>
<field name="name">dashboard_item_list</field>
</record>
<record model="ir.action.act_window" id="act_babi_dashboard_item">
<field name="name">Dashboard Items</field>
<field name="res_model">babi.dashboard.item</field>
</record>
<record model="ir.action.act_window.view" id="act_babi_dashboard_item_view1">
<field name="sequence" eval="10"/>
<field name="view" ref="babi_dashboard_item_tree_view"/>
<field name="act_window" ref="act_babi_dashboard_item"/>
</record>
<record model="ir.action.act_window.view" id="act_babi_dashboard_item_view2">
<field name="sequence" eval="20"/>
<field name="view" ref="babi_dashboard_item_form_view"/>
<field name="act_window" ref="act_babi_dashboard_item"/>
</record>
<record model="ir.model.access" id="access_babi_dashboard_item">
<field name="model" search="[('model', '=', 'babi.dashboard.item')]"/>
<field name="perm_read" eval="False"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_delete" eval="False"/>
</record>
<record model="ir.model.access" id="access_babi_dashboard_item_babi">
<field name="model" search="[('model', '=', 'babi.dashboard.item')]"/>
<field name="group" ref="group_babi"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_delete" eval="True"/>
</record>
<!-- babi.widget -->
<record model="ir.ui.view" id="babi_widget_form_view">
<field name="model">babi.widget</field>
<field name="type">form</field>
<field name="name">widget_form</field>
</record>
<record model="ir.ui.view" id="babi_widget_tree_view">
<field name="model">babi.widget</field>
<field name="type">tree</field>
<field name="name">widget_list</field>
</record>
<record model="ir.action.act_window" id="act_babi_widget">
<field name="name">Widgets</field>
<field name="res_model">babi.widget</field>
</record>
<record model="ir.action.act_window.view" id="act_babi_widget_view1">
<field name="sequence" eval="10"/>
<field name="view" ref="babi_widget_tree_view"/>
<field name="act_window" ref="act_babi_widget"/>
</record>
<record model="ir.action.act_window.view" id="act_babi_widget_view2">
<field name="sequence" eval="20"/>
<field name="view" ref="babi_widget_form_view"/>
<field name="act_window" ref="act_babi_widget"/>
</record>
<record model="ir.model.access" id="access_babi_widget">
<field name="model" search="[('model', '=', 'babi.widget')]"/>
<field name="perm_read" eval="False"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_delete" eval="False"/>
</record>
<record model="ir.model.access" id="access_babi_widget_babi">
<field name="model" search="[('model', '=', 'babi.widget')]"/>
<field name="group" ref="group_babi"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_delete" eval="True"/>
</record>
<menuitem id="menu_widget" parent="menu_dashboard" sequence="1" action="act_babi_widget"/>
<!-- babi.widget.parameter -->
<record model="ir.ui.view" id="babi_widget_parameter_form_view">
<field name="model">babi.widget.parameter</field>
<field name="type">form</field>
<field name="name">widget_parameter_form</field>
</record>
<record model="ir.ui.view" id="babi_widget_parameter_tree_view">
<field name="model">babi.widget.parameter</field>
<field name="type">tree</field>
<field name="name">widget_parameter_list</field>
</record>
</data>
</tryton>

View File

@ -90,5 +90,11 @@ Exception: %(error)s</field>
%(error)s</field>
</record>
<record model="ir.message" id="msg_invalid_aggregate">
<field name="text">Aggregates "Sum" and "Average" are not supported in non numeric fields in parameter "%(parameter)s" in widget "%(widget)s".</field>
</record>
<record model="ir.message" id="msg_invalid_parameter_type">
<field name="text">Parameter "%(parameter)s" is not valid in widget "%(widget)s". Only the following types can be used: "%(types)s".</field>
</record>
</data>
</tryton>

325
table.py
View File

@ -7,17 +7,20 @@ from simpleeval import EvalWithCompoundTypes
from trytond.bus import notify
from trytond.transaction import Transaction
from trytond.pool import Pool
from trytond.model import ModelView, ModelSQL, fields, Unique, DeactivableMixin
from trytond.model import (ModelView, ModelSQL, fields, Unique,
DeactivableMixin, sequence_ordered)
from trytond.exceptions import UserError
from trytond.i18n import gettext
from trytond.pyson import Eval, PYSONDecoder
from .babi import TimeoutChecker, TimeoutException
from trytond import backend
from .babi import TimeoutChecker, TimeoutException, FIELD_TYPES
from .babi_eval import babi_eval
VALID_FIRST_SYMBOLS = 'abcdefghijklmnopqrstuvwxyz'
VALID_NEXT_SYMBOLS = '_0123456789'
VALID_SYMBOLS = VALID_FIRST_SYMBOLS + VALID_NEXT_SYMBOLS
logger = logging.getLogger(__name__)
def convert_to_symbol(text):
@ -46,14 +49,30 @@ class Table(DeactivableMixin, ModelSQL, ModelView):
'BABI Table'
__name__ = 'babi.table'
name = fields.Char('Name', required=True)
type = fields.Selection([
(None, ''),
('model', 'Model'),
('table', 'Table'),
('query', 'Query'),
], 'Type', required=True)
internal_name = fields.Char('Internal Name', required=True)
model = fields.Many2One('ir.model', 'Model', required=True,
domain=[('babi_enabled', '=', True)])
model = fields.Many2One('ir.model', 'Model', states={
'invisible': Eval('type') != 'model',
'required': Eval('type') == 'model',
}, domain=[('babi_enabled', '=', True)])
filter = fields.Many2One('babi.filter', 'Filter', domain=[
('model', '=', Eval('model')),
], depends=['model'])
], states={
'invisible': Eval('type') != 'model',
}, depends=['model'])
fields_ = fields.One2Many('babi.field', 'table', 'Fields')
timeout = fields.Integer('Timeout', required=True, help='If table '
query = fields.Text('Query', states={
'invisible': ~Eval('type').in_(['query', 'table']),
'required': Eval('type').in_(['query', 'table']),
}, depends=['type'])
timeout = fields.Integer('Timeout', required=True, states={
'invisible': ~Eval('type').in_(['model', 'table']),
}, help='If table '
'calculation should take more than the specified timeout (in seconds) '
'the process will be stopped automatically.')
babi_raise_user_error = fields.Boolean('Raise User Error',
@ -134,118 +153,242 @@ class Table(DeactivableMixin, ModelSQL, ModelView):
for table in tables:
cls.__queue__._compute(table)
@property
def table_name(self):
# Add a suffix to the table name to prevent removing production tables
return '__' + self.internal_name
def get_query(self, fields=None, where=None, groupby=None, limit=None):
query = 'SELECT '
if fields:
query += ', '.join(fields) + ' '
else:
query += '* '
if self.type == 'query':
query += 'FROM (%s) AS a ' % self._stripped_query
else:
query += 'FROM %s ' % self.table_name
if where:
where = where.format(**Transaction().context)
query += 'WHERE %s ' % where
if groupby:
query += 'GROUP BY %s ' % ', '.join(groupby) + ' '
query += 'ORDER BY %s' % ', '.join(fields)
return query
def execute_query(self, fields=None, where=None, groupby=None, timeout=None,
limit=None):
if timeout is None:
timeout = 10
if not backend.TableHandler.table_exist(self.table_name):
return []
with Transaction().new_transaction() as transaction:
cursor = transaction.connection.cursor()
cursor.execute('SET statement_timeout TO %s;' % timeout * 1000)
query = self.get_query(fields, where=where, groupby=groupby,
limit=limit)
cursor.execute(query)
records = cursor.fetchall()
return records
def timeout_exception(self):
raise TimeoutException
def _compute(self):
if not self.fields_:
raise UserError(gettext('babi.msg_table_no_fields',
table=self.name))
if self.type == 'model':
if not self.fields_:
raise UserError(gettext('babi.msg_table_no_fields',
table=self.name))
if self.filter and self.filter.parameters:
raise UserError(gettext('babi.msg_filter_with_parameters',
table=self.rec_name))
if self.filter and self.filter.parameters:
raise UserError(gettext('babi.msg_filter_with_parameters',
table=self.rec_name))
if self.type == 'model':
self._compute_model()
elif self.type == 'table':
self._compute_table()
elif self.type == 'query':
self._compute_query()
def update_fields(self, field_names):
pool = Pool()
Field = pool.get('babi.field')
# Update self.fields_
to_save = []
to_delete = []
existing_fields = set([])
for field in self.fields_:
if field.internal_name not in field_names:
to_delete.append(field)
continue
field.sequence = field_names.index(field.internal_name)
existing_fields.add(field.internal_name)
to_save.append(field)
for field_name in (set(field_names) - existing_fields):
field = Field()
field.table = self
field.name = field_name
field.internal_name = field_name
field.sequence = field_names.index(field.internal_name)
to_save.append(field)
Field.save(to_save)
Field.delete(to_delete)
@property
def _stripped_query(self):
if self.query:
return self.query.strip().rstrip(';')
else:
return ''
def _compute_query(self):
with Transaction().new_transaction() as transaction:
cursor = Transaction().connection.cursor()
cursor.execute('%s LIMIT 1' % self._stripped_query)
field_names = [x[0] for x in cursor.description]
self.update_fields(field_names)
def _compute_table(self):
with Transaction().new_transaction() as transaction:
cursor = Transaction().connection.cursor()
cursor.execute('DROP TABLE IF EXISTS "%s"' % self.table_name)
cursor.execute('CREATE TABLE "%s" AS %s' % (self.table_name,
self._stripped_query))
cursor.execute('SELECT * FROM "%s" LIMIT 1' % self.table_name)
field_names = [x[0] for x in cursor.description]
self.update_fields(field_names)
def _compute_model(self):
Model = Pool().get(self.model.model)
cursor = Transaction().connection.cursor()
with Transaction().new_transaction() as transaction:
cursor = Transaction().connection.cursor()
# Create table
cursor.execute('DROP TABLE IF EXISTS "%s"' % self.internal_name)
fields = []
for field in self.fields_:
fields.append('"%s" %s' % (field.internal_name, field.sql_type()))
cursor.execute('CREATE TABLE IF NOT EXISTS "%s" (%s);' % (
self.internal_name, ', '.join(fields)))
cursor.execute('DROP TABLE IF EXISTS "%s"' % self.table_name)
fields = []
for field in self.fields_:
fields.append('"%s" %s' % (field.internal_name, field.sql_type()))
cursor.execute('CREATE TABLE IF NOT EXISTS "%s" (%s);' % (
self.table_name, ', '.join(fields)))
checker = TimeoutChecker(self.timeout, self.timeout_exception)
domain = self.get_domain_filter()
checker = TimeoutChecker(self.timeout, self.timeout_exception)
domain = self.get_domain_filter()
context = self.get_context()
if not context:
context = {}
else:
assert isinstance(context, dict)
context['_datetime'] = None
# This is needed when execute the wizard to calculate the report, to
# ensure the company rule is used.
context['_check_access'] = True
context = self.get_context()
if not context:
context = {}
else:
assert isinstance(context, dict)
context['_datetime'] = None
# This is needed when execute the wizard to calculate the report, to
# ensure the company rule is used.
context['_check_access'] = True
python_filter = self.get_python_filter()
python_filter = self.get_python_filter()
table = sql.Table(self.internal_name)
columns = [sql.Column(table, x.internal_name) for x in self.fields_]
expressions = [x.expression.expression for x in self.fields_]
index = 0
count = 0
offset = 2000
table = sql.Table(self.table_name)
columns = [sql.Column(table, x.internal_name) for x in self.fields_]
expressions = [x.expression.expression for x in self.fields_]
index = 0
count = 0
offset = 2000
with Transaction().set_context(**context):
try:
records = Model.search(domain, offset=index * offset,
limit=offset)
except Exception as message:
if self.babi_raise_user_error:
raise UserError(gettext(
'babi.create_data_exception',
error=repr(message)))
raise
while records:
checker.check()
logger.info('Calculated %s, %s records in %s seconds'
% (self.model.model, count, checker.elapsed))
to_insert = []
for record in records:
if python_filter:
if not babi_eval(python_filter, record, convert_none=None):
continue
values = []
for expression in expressions:
try:
values.append(babi_eval(expression, record,
convert_none=None))
except Exception as message:
notify(gettext('babi.msg_compute_table_exception',
table=self.name, field=field.name,
record=record.id, error=repr(message)),
priority=1)
if self.babi_raise_user_error:
raise UserError(gettext(
'babi.msg_compute_table_exception',
table=self.name,
field=field.name,
record=record.id,
error=repr(message)))
raise
to_insert.append(values)
cursor.execute(*table.insert(columns=columns, values=to_insert))
index += 1
count += len(records)
with Transaction().set_context(**context):
records = Model.search(domain, offset=index * offset,
limit=offset)
try:
records = Model.search(domain, offset=index * offset,
limit=offset)
except Exception as message:
if self.babi_raise_user_error:
raise UserError(gettext(
'babi.create_data_exception',
error=repr(message)))
raise
while records:
checker.check()
logger.info('Calculated %s, %s records in %s seconds'
% (self.model.model, count, checker.elapsed))
to_insert = []
for record in records:
if python_filter:
if not babi_eval(python_filter, record, convert_none=None):
continue
values = []
for expression in expressions:
try:
values.append(babi_eval(expression, record,
convert_none=None))
except Exception as message:
notify(gettext('babi.msg_compute_table_exception',
table=self.name, field=field.name,
record=record.id, error=repr(message)),
priority=1)
if self.babi_raise_user_error:
raise UserError(gettext(
'babi.msg_compute_table_exception',
table=self.name,
field=field.name,
record=record.id,
error=repr(message)))
raise
to_insert.append(values)
cursor.execute(*table.insert(columns=columns, values=to_insert))
index += 1
count += len(records)
with Transaction().set_context(**context):
records = Model.search(domain, offset=index * offset,
limit=offset)
logger.info('Calculated %s, %s records in %s seconds'
% (self.model.model, count, checker.elapsed))
class Field(ModelSQL, ModelView):
class Field(sequence_ordered(), ModelSQL, ModelView):
'BABI Field'
__name__ = 'babi.field'
table = fields.Many2One('babi.table', 'Table', required=True)
name = fields.Char('Name', required=True)
internal_name = fields.Char('Internal Name', required=True)
expression = fields.Many2One('babi.expression', 'Expression', required=True,
domain=[
expression = fields.Many2One('babi.expression', 'Expression', states={
'invisible': Eval('table_type') != 'model',
'required': Eval('table_type') == 'model'
}, domain=[
('model', '=', Eval('model')),
], depends=['model'])
model = fields.Function(fields.Many2One('ir.model', 'Model'),
'on_change_with_model')
type = fields.Function(fields.Selection(FIELD_TYPES, 'Type'),
'on_change_with_type')
table_type = fields.Function(fields.Selection([
('model', 'Model'),
('table', 'Table'),
('query', 'Query'),
], 'Table Type'), 'on_change_with_table_type')
@fields.depends('expression')
def on_change_with_type(self, name=None):
if self.expression:
return self.expression.ttype
@fields.depends('table', '_parent_table.type')
def on_change_with_table_type(self, name=None):
if self.table:
return self.table.type
@classmethod
def __setup__(cls):
@ -295,7 +438,7 @@ class Field(ModelSQL, ModelView):
self.name = self.expression.name
self.on_change_name()
@fields.depends('table', '_parent_table.id')
@fields.depends('table', '_parent_table.model')
def on_change_with_model(self, name=None):
if self.table:
if self.table and self.table.model:
return self.table.model.id

View File

@ -52,7 +52,7 @@
<field name="model" search="[('model', '=', 'babi.table')]"/>
</record>
<menuitem id="menu_babi_table" parent="menu_babi" action="act_babi_table" sequence="10"/>
<menuitem id="menu_babi_table" parent="menu_configuration" action="act_babi_table" sequence="30"/>
<!-- babi.field -->
<record model="ir.ui.view" id="babi_field_form_view">

View File

@ -717,9 +717,10 @@ class BabiTestCase(CompanyTestMixin, ModuleTestCase):
self.create_data()
table = Table()
table.name = 'Table'
table.type = 'model'
table.name = 'Table 1'
table.on_change_name()
self.assertEqual(table.internal_name, 'table')
self.assertEqual(table.internal_name, 'table_1')
table.model, = Model.search([('model', '=', 'babi.test')])
fields = []
@ -739,9 +740,25 @@ class BabiTestCase(CompanyTestMixin, ModuleTestCase):
table._compute()
cursor = Transaction().connection.cursor()
cursor.execute('SELECT count(*) FROM "%s"' % table.internal_name)
cursor.execute('SELECT count(*) FROM "%s"' % table.table_name)
count = cursor.fetchall()[0][0]
self.assertNotEqual(count, 0)
table = Table()
table.type = 'table'
table.name = 'Table 2'
table.on_change_name()
table.query = 'SELECT amount, category FROM babi_test'
table.save()
table._compute()
fields = [x.internal_name for x in table.fields_]
self.assertEqual(fields, ['amount', 'category'])
table.query = 'SELECT date, amount FROM babi_test'
table.save()
table._compute()
fields = [x.internal_name for x in table.fields_]
self.assertEqual(fields, ['date', 'amount'])
del ModuleTestCase

View File

@ -12,3 +12,5 @@ xml:
configuration.xml
cron.xml
messages.xml
dashboard.xml
action.xml

View File

@ -0,0 +1,20 @@
<form>
<label name="name"/>
<field name="name"/>
<label name="active"/>
<field name="active" xexpand="0" width="100"/>
<notebook colspan="4">
<page string="General" id="general">
<label name="dashboard"/>
<field name="dashboard"/>
<label name="icon"/>
<field name="icon"/>
</page>
<page name="keywords">
<field name="keywords" colspan="4"/>
</page>
<page name="groups">
<field name="groups" colspan="4"/>
</page>
</notebook>
</form>

View File

@ -0,0 +1,5 @@
<tree>
<field name="name" expand="2"/>
<field name="type" expand="1"/>
<field name="dashboard" expand="1"/>
</tree>

6
view/dashboard_form.xml Normal file
View File

@ -0,0 +1,6 @@
<form>
<label name="name"/>
<field name="name"/>
<button name="show" colspan="2"/>
<field name="widgets" colspan="4"/>
</form>

View File

@ -0,0 +1,10 @@
<form col="6">
<label name="dashboard"/>
<field name="dashboard"/>
<label name="widget"/>
<field name="widget"/>
<label name="sequence"/>
<field name="sequence"/>
<label name="colspan"/>
<field name="colspan"/>
</form>

View File

@ -0,0 +1,5 @@
<tree sequence="sequence" editable="1">
<field name="dashboard"/>
<field name="widget"/>
<field name="colspan"/>
</tree>

4
view/dashboard_list.xml Normal file
View File

@ -0,0 +1,4 @@
<tree>
<field name="name"/>
<button name="show"/>
</tree>

View File

@ -1,4 +1,4 @@
<tree editable="1">
<tree editable="1" sequence="sequence">
<field name="table"/>
<field name="expression"/>
<field name="name"/>

View File

@ -3,11 +3,16 @@
<field name="name"/>
<label name="internal_name"/>
<field name="internal_name"/>
<label name="type"/>
<field name="type"/>
<label name="model"/>
<field name="model"/>
<label name="filter"/>
<field name="filter" colspan="3"/>
<field name="filter"/>
<button name="compute" colspan="2"/>
<newline/>
<separator name="query" colspan="6"/>
<field name="query" colspan="6"/>
<notebook colspan="6">
<page name="fields_">
<field name="fields_" colspan="4"/>

23
view/widget_form.xml Normal file
View File

@ -0,0 +1,23 @@
<form col="6">
<label name="name"/>
<field name="name"/>
<label name="type"/>
<field name="type"/>
<label name="table"/>
<field name="table"/>
<label name="where"/>
<field name="where" colspan="5"/>
<label name="timeout"/>
<field name="timeout"/>
<newline/>
<notebook colspan="4">
<page name="parameters">
<field name="parameters" colspan="4"/>
</page>
<page id="settings" string="Settings">
<label name="show_legend"/>
<field name="show_legend"/>
</page>
</notebook>
<field name="chart" widget="chart" colspan="2"/>
</form>

6
view/widget_list.xml Normal file
View File

@ -0,0 +1,6 @@
<tree>
<field name="name"/>
<field name="type"/>
<field name="table"/>
<field name="where"/>
</tree>

View File

@ -0,0 +1,11 @@
<form col="6">
<label name="widget"/>
<field name="widget"/>
<newline/>
<label name="type"/>
<field name="type"/>
<label name="field"/>
<field name="field"/>
<label name="aggregate"/>
<field name="aggregate"/>
</form>

View File

@ -0,0 +1,6 @@
<tree editable="1">
<field name="widget"/>
<field name="type"/>
<field name="field" expand="1"/>
<field name="aggregate"/>
</tree>