trytond-babi/babi.py

2960 lines
108 KiB
Python

# encoding: utf-8
# The COPYRIGHT file at the top level of this repository contains the full
# copyright notices and license terms.
import datetime as mdatetime
import pytz
import logging
import os
import time
import unicodedata
import json
import itertools
import html
from simpleeval import EvalWithCompoundTypes
from psycopg2.errors import InvalidTextRepresentation
from sql import Null, Column
from sql.operators import Or
from datetime import datetime, timedelta
from collections import defaultdict
from io import BytesIO
from trytond.wizard import (Wizard, StateView, StateAction, StateTransition,
StateReport, Button)
from trytond.model import (DeactivableMixin, ModelSQL, ModelView, fields,
Unique, Check, sequence_ordered)
from trytond.model.fields import depends
from trytond.pyson import (Bool, Eval, Id, If, In, Not, Or as pysonOr,
PYSONDecoder, PYSONEncoder)
from trytond.pool import Pool, PoolMeta
from trytond.transaction import Transaction
from trytond.tools import grouped_slice
from trytond.config import config as config_
from trytond import backend
from trytond.protocols.jsonrpc import JSONDecoder, JSONEncoder
from .babi_eval import babi_eval
from trytond.i18n import gettext
from trytond.exceptions import UserError, UserWarning
from trytond.model.modelstorage import AccessError
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.base import MIMEBase
from email.header import Header
from email.utils import getaddresses
from email import encoders
from trytond.bus import notify
FIELD_TYPES = [
('char', 'Char'),
('integer', 'Integer'),
('float', 'Float'),
('numeric', 'Numeric'),
('boolean', 'Boolean'),
('many2one', 'Many To One'),
('date', 'Date'),
('datetime', 'Date & Time'),
]
AGGREGATE_TYPES = [
('avg', 'Average'),
('sum', 'Sum'),
('count', 'Count'),
('max', 'Max'),
('min', 'Min'),
]
# Red Circle Emoji
FIRE_HTML = '🔥'
FIRE = html.unescape(FIRE_HTML)
TICK_HTML = '✅'
TICK = html.unescape(TICK_HTML)
SRC_CHARS = """ .'"()/*-+?¿!&$[]{}@#`'^:;<>=~%,|\\"""
DST_CHARS = """__________________________________"""
RETENTION_DAYS = config_.getint('babi', 'retention_days', default=30)
MAX_BD_COLUMN = config_.getint('babi', 'max_db_column', default=60)
QUEUE_NAME = config_.get('babi', 'queue_name', default='default')
logger = logging.getLogger(__name__)
def unaccent(text):
if not (isinstance(text, str)):
return str(text)
if isinstance(text, str) and bytes == str:
text = str(text, 'utf-8')
text = text.lower()
for c in range(len(SRC_CHARS)):
if c >= len(DST_CHARS):
break
text = text.replace(SRC_CHARS[c], DST_CHARS[c])
text = unicodedata.normalize('NFKD', text)
if bytes == str:
text = text.encode('ASCII', 'ignore')
return text
def _replace(x):
return x.replace("'", '')
def sanitanize(x):
if (isinstance(x, str) or isinstance(x, str)
or isinstance(x, str)):
x = x.replace('|', '-')
if not isinstance(x, str) and isinstance(x, str):
return str(x.decode('utf-8'))
else:
return str(x)
class DimensionError(UserError):
pass
class MeasureError(UserError):
pass
class DynamicModel(ModelSQL, ModelView):
@classmethod
def __setup__(cls):
super(DynamicModel, cls).__setup__()
pool = Pool()
Execution = pool.get('babi.report.execution')
executions = Execution.search([
('babi_model.model', '=', cls.__name__),
])
if not executions or len(executions) > 1:
return
execution, = executions
try:
cls._order = execution.get_orders()
except AssertionError:
# An exception error is raisen on tests where Execution is not
# properly loaded in the pool
pass
@classmethod
def fields_view_get(cls, view_id=None, view_type='form'):
pool = Pool()
Execution = pool.get('babi.report.execution')
Dimension = pool.get('babi.dimension')
InternalMeasure = pool.get('babi.internal.measure')
model_name = '_'.join(cls.__name__.split('_')[0:2])
executions = Execution.search([
('babi_model.model', '=', cls.__name__),
], limit=1)
if not executions:
raise UserError(gettext('babi.report_not_exists',
report=cls.__name__))
context = Transaction().context
execution, = executions
with Transaction().set_context(_datetime=execution.create_date):
report = execution.report
view_type = context.get('view_type', view_type)
result = {}
result['type'] = view_type
result['view_id'] = view_id
result['field_childs'] = None
fields = []
if view_type == 'tree' or view_type == 'form':
keyword = ''
if view_type == 'tree':
keyword = 'keyword_open="1"'
fields.append('children')
xml = '<%s string="%s" %s>\n' % (view_type, report.model.name,
keyword)
for field in report.dimensions + execution.internal_measures:
# Avoid duplicated fields
if field.internal_name in fields:
continue
attributes = ''
if view_type == 'tree':
attributes += 'optional="0" '
if field.expand:
attributes += f'expand="{field.expand}" '
elif view_type == 'form':
xml += f'<label name="{field.internal_name}"/>\n'
xml += f'<field name="{field.internal_name}" {attributes} />\n'
fields.append(field.internal_name)
xml += '</%s>\n' % (view_type)
result['arch'] = xml
if view_type == 'tree' and context.get('babi_tree_view'):
result['field_childs'] = 'children'
elif view_type == 'graph':
model_name = context.get('model_name')
graph_type = context.get('graph_type')
measure_ids = context.get('measures')
legend = context.get('legend') and 1 or 0
interpolation = context.get('interpolation', 'linear')
dimension = Dimension(context.get('dimension'))
x_xml = '<field name="%s"/>\n' % dimension.internal_name
fields.append(dimension.internal_name)
y_xml = ''
for i, measure in enumerate(InternalMeasure.browse(
measure_ids)):
y_xml += '''<field name="%(internal_name)s" interpolation="%(interpolation)s"/>\n''' % {
'internal_name': measure.internal_name,
'interpolation': interpolation,
}
fields.append(measure.internal_name)
xml = '''<?xml version="1.0"?>
<graph string="%(graph_name)s" type="%(graph_type)s" legend="%(legend)s">
<x>
%(x_fields)s
</x>
<y>
%(y_fields)s
</y>
</graph>''' % {
'graph_type': graph_type,
'graph_name': model_name,
'legend': legend,
'x_fields': x_xml,
'y_fields': y_xml,
}
result['arch'] = xml
else:
assert False
result['fields'] = cls.fields_get(fields)
return result
def get_rec_name(self, name):
result = []
for field in self._babi_dimensions:
value = getattr(self, field)
if not value:
result.append('-')
elif isinstance(value, ModelSQL):
result.append(value.rec_name)
elif not isinstance(value, str):
result.append(str(value))
else:
result.append(value)
return ' / '.join(result)
def create_columns(name, ffields):
"Create fields of new model"
columns = {}
for field in ffields:
fname = field['name']
field_name = field['internal_name']
ttype = field['ttype']
digits = field['decimal_digits']
if digits is None:
digits = 2
if ttype == 'integer':
columns[field_name] = fields.Integer(fname)
elif ttype == 'float':
columns[field_name] = fields.Float(fname, digits=(16, digits))
elif ttype == 'numeric':
columns[field_name] = fields.Numeric(fname, digits=(16, digits))
elif ttype == 'char':
columns[field_name] = fields.Char(fname)
elif ttype == 'date':
columns[field_name] = fields.Date(fname)
elif ttype == 'datetime':
columns[field_name] = fields.DateTime(fname)
elif ttype == 'boolean':
columns[field_name] = fields.Boolean(fname)
elif ttype == 'many2one':
columns[field_name] = fields.Many2One(field['related_model'],
fname, ondelete='SET NULL')
else:
raise ValueError("Unknown type: %s" % ttype)
columns['babi_group'] = fields.Char('Group', size=500)
columns['parent'] = fields.Many2One(name, 'Parent', ondelete='CASCADE',
left='parent_left', right='parent_right')
columns['children'] = fields.One2Many(name, 'parent', 'Children')
columns['parent_left'] = fields.Integer('Parent Left')
columns['parent_right'] = fields.Integer('Parent Right')
return columns
def create_class(name, description, dimensions, measures):
"Create class, and make instance"
body = {
'__doc__': description,
'__name__': name,
# Used in get_rec_name()
'_defaults': {},
'_babi_dimensions': [x['internal_name'] for x in dimensions],
}
body.update(create_columns(name, dimensions + measures))
return type(name, (DynamicModel, ), body)
def register_class(internal_name, name, dimensions, measures,
avoid_registration=False):
"Register class an return model"
pool = Pool()
Model = pool.get('ir.model')
Class = create_class(internal_name, name, dimensions, measures)
Pool.register(Class, module='babi', type_='model')
Class.__setup__()
pool.add(Class, type='model')
Class.__post_setup__()
if avoid_registration:
models = Model.search([
('model', '=', internal_name),
])
if models:
return models[0]
# Register only if the model does not yet exist as otherwise
# we can have concurrency errors on the database when ir.model's
# register() method updates name and info fields in the database
Class.__register__('babi')
model, = Model.search([
('model', '=', internal_name),
])
return model
def create_groups_access(model, groups):
"Creates group access for a given model"
pool = Pool()
ModelAccess = pool.get('ir.model.access')
to_create = []
for group in groups:
exists = ModelAccess.search([
('model', '=', model.id),
('group', '=', group.id),
])
if not exists:
to_create.append({
'model': model.id,
'group': group.id,
'perm_read': True,
'perm_create': True,
'perm_write': True,
'perm_delete': True,
})
if to_create:
ModelAccess.create(to_create)
class TimeoutException(Exception):
pass
class TimeoutChecker:
def __init__(self, timeout, callback):
self._timeout = timeout
self._callback = callback
self._start = datetime.now()
@property
def elapsed(self):
return (datetime.now() - self._start).seconds
def check(self):
if self.elapsed > self._timeout:
self._callback()
class DimensionIterator:
def __init__(self, values):
"""
values should be a dictionary where its values are
non-empty lists.
"""
self.values = values
self.keys = sorted(values.keys())
self.keys.reverse()
self.current = dict.fromkeys(values.keys(), 0)
self.current[self.keys[0]] = -1
def __iter__(self):
return self
def __next__(self):
for x in range(len(self.keys)):
key = self.keys[x]
if self.current[key] >= len(self.values[key]) - 1:
if x == len(self.keys) - 1:
raise StopIteration
self.current[key] = 0
else:
self.current[key] += 1
break
return self.current
class Filter(DeactivableMixin, ModelSQL, ModelView):
"Filter"
__name__ = 'babi.filter'
_history = True
name = fields.Char('Name', required=True, translate=True)
model = fields.Many2One('ir.model', 'Model', required=True,
domain=[('babi_enabled', '=', True)])
model_name = fields.Function(fields.Char('Model Name'),
'on_change_with_model_name')
view_search = fields.Many2One('ir.ui.view_search', 'Search',
domain=[('model', '=', Eval('model_name'))],
depends=['model_name'])
checked = fields.Boolean('Checked', readonly=True)
domain = fields.Char('Domain')
domain_error = fields.Char('Domain Error', readonly=True, states={
'invisible': ~Bool(Eval('domain_error')),
})
python_expression = fields.Char('Python Expression',
help='The python expression introduced will be evaluated. If the '
'result is True the record will be included, it will be discarded '
'otherwise.')
expression_error = fields.Char('Expression Error', readonly=True, states={
'invisible': ~Bool(Eval('expression_error')),
})
context = fields.Char('Context',
help="A dict to eval context:\n"
"- date: eval string to date. Format: %Y-%m-%d.\n"
" Example: {'stock_date_end': date('2023-12-01')}\n"
"- datetime: eval string to date time. Format %Y-%m-%d %H:%M:%S.\n"
" Example: {'name': datetime('2023-12-01 10:00:00')}\n"
"- today: eval current date\n"
" Example: {'stock_date_end': today}")
parameters = fields.One2Many('babi.filter.parameter', 'filter',
'Parameters',
states={
'invisible': Not(Eval('context', {}).get('groups', []).contains(
Id('babi', 'group_babi_admin'))),
})
fields = fields.Function(fields.Many2Many('ir.model.field', None, None,
'Model Fields', depends=['model']),
'on_change_with_fields')
@depends('model')
def on_change_with_model_name(self, name=None):
return self.model.model if self.model else None
@depends('model')
def on_change_with_fields(self, name=None):
if not self.model:
return []
return [x.id for x in self.model.fields]
@depends('view_search')
def on_change_with_domain(self):
return self.view_search.domain if self.view_search else None
@depends('model_name', 'domain')
def on_change_with_view_search(self):
ViewSearch = Pool().get('ir.ui.view_search')
searches = ViewSearch.search([
('model', '=', self.model_name),
('domain', '=', self.domain),
])
if not searches:
return None
return searches[0].id
def get_rec_name(self, name):
name = ''
if self.checked:
if self.domain_error or self.expression_error:
name = FIRE
else:
name = TICK
name += ' ' + self.name
return name
@classmethod
def __setup__(cls):
super().__setup__()
cls._buttons.update({
'check': {},
})
@classmethod
def write(cls, *args):
args = [x.copy() for x in args]
actions = iter(args)
for filters, values in zip(actions, actions):
if 'python_expression' in values:
values.setdefault('checked', False)
values.setdefault('expression_error')
if 'domain' in values:
values.setdefault('checked', False)
values.setdefault('domain_error')
super().write(*args)
@classmethod
@ModelView.button
def check(cls, filters=None):
if not filters:
filters = cls.search([])
for filter in filters:
filter.single_check()
cls.save(filters)
def single_check(self, cumulate=False):
pool = Pool()
self.checked = True
self.domain_error = None
self.expression_error = None
if self.parameters:
return True
Model = pool.get(self.model.model)
records = None
if self.domain:
domain = self.domain
if '__' in domain:
domain = str(PYSONDecoder().decode(domain))
try:
domain = eval(domain, {
'datetime': mdatetime,
'false': False,
'true': True,
})
records = Model.search(domain, limit=100,
order=[('id', 'DESC')])
except Exception as e:
self.domain_error = str(e)
expression = self.python_expression
if not expression:
return
records = Model.search([], limit=100, order=[('id', 'DESC')])
records += Model.search([], limit=100, order=[('id', 'ASC')])
start = time.time()
for record in records:
try:
babi_eval(expression, record, convert_none=False)
except Exception as e:
self.expression_error = str(e)
if time.time() - start > 5:
logger.info('Waited too much for expression: %s',
expression)
break
class FilterParameter(ModelSQL, ModelView):
"Filter Parameter"
__name__ = 'babi.filter.parameter'
_history = True
filter = fields.Many2One('babi.filter', 'Filter', required=True)
name = fields.Char('Name', required=True, translate=True, help='Name used '
'on the domain substitution')
ttype = fields.Selection(FIELD_TYPES + [('many2many', 'Many To Many')],
'Field Type', required=True)
related_model = fields.Many2One('ir.model', 'Related Model', states={
'required': Eval('ttype').in_(['many2one', 'many2many']),
'readonly': Not(Eval('ttype').in_(['many2one', 'many2many'])),
}, depends=['ttype'])
def create_keyword(self):
pool = Pool()
Action = pool.get('ir.action.wizard')
ModelData = pool.get('ir.model.data')
Keyword = pool.get('ir.action.keyword')
if self.ttype in ['many2one', 'many2many']:
action = Action(ModelData.get_id('babi', 'open_execution_wizard'))
keyword = Keyword()
keyword.keyword = 'form_relate'
keyword.model = '%s,-1' % self.related_model.model
keyword.action = action.action
keyword.babi_filter_parameter = self
keyword.save()
@classmethod
def __register__(cls, module_name):
super(FilterParameter, cls).__register__(module_name)
cursor = Transaction().connection.cursor()
sql_table = cls.__table__()
# Migration from int to integer
cursor.execute(*sql_table.update([Column(sql_table, 'ttype')],
['integer'], where=sql_table.ttype == 'int'))
# Migration from bool to boolean
cursor.execute(*sql_table.update([Column(sql_table, 'ttype')],
['boolean'], where=sql_table.ttype == 'bool'))
def check_parameter_in_filter(self):
Warning = Pool().get('res.user.warning')
placeholder = '{%s}' % self.name
if ((self.filter.domain and placeholder not in self.filter.domain)
and (self.filter.python_expression
and placeholder not in self.filter.python_expression)):
key = 'task_babi_check_parameter_in_filter.%d' % self.id
if Warning.check(key):
raise UserWarning('babi_check_parameter_in_filter.{}'.format(
self.name), gettext('babi.parameter_not_found',
parameter=self.rec_name,
filter=self.filter.rec_name))
return False
return True
@classmethod
def create(cls, vlist):
filters = super(FilterParameter, cls).create(vlist)
with Transaction().set_context(_check_access=False):
for filter in filters:
filter.create_keyword()
return filters
@classmethod
def write(cls, *args):
pool = Pool()
Keyword = pool.get('ir.action.keyword')
super(FilterParameter, cls).write(*args)
actions = iter(args)
with Transaction().set_context(_check_access=False):
for filters, values in zip(actions, actions):
if 'related_model' in values:
filter_ids = [f.id for f in filters]
Keyword.delete(Keyword.search([
('babi_filter_parameter', 'in', filter_ids),
]))
for filter in filters:
filter.create_keyword()
@classmethod
def delete(cls, filters):
pool = Pool()
Keyword = pool.get('ir.action.keyword')
with Transaction().set_context(_check_access=False):
Keyword.delete(Keyword.search([
('babi_filter_parameter', 'in',
[f.id for f in filters]),
]))
super(FilterParameter, cls).delete(filters)
class Expression(DeactivableMixin, ModelSQL, ModelView):
"Expression"
__name__ = 'babi.expression'
_history = True
name = fields.Char('Name', required=True, translate=True)
model = fields.Many2One('ir.model', 'Model', required=True,
domain=[('babi_enabled', '=', True)])
checked = fields.Boolean('Checked', readonly=True)
error = fields.Char('Error', readonly=True, states={
'invisible': ~Bool(Eval('error')),
})
expression = fields.Char('Expression', required=True,
help='Python expression that will return the value to be used.\n'
'The expression can include the following variables:\n\n'
'- "o": A reference to the current record being processed. For '
' example: "o.party.name"\n'
'\nAnd the following functions apply to dates and timestamps:\n\n'
'- "y()": Returns the year (as a string)\n'
'- "m()": Returns the month (as a string)\n'
'- "w()": Returns the week (as a string)\n'
'- "d()": Returns the day (as a string)\n'
'- "ym()": Returns the year-month (as a string)\n'
'- "ymd()": Returns the year-month-day (as a string).\n')
ttype = fields.Selection(FIELD_TYPES, 'Field Type', required=True)
related_model = fields.Many2One('ir.model', 'Related Model', states={
'required': Eval('ttype') == 'many2one',
'readonly': Eval('ttype') != 'many2one',
'invisible': Eval('ttype') != 'many2one',
}, depends=['ttype'])
decimal_digits = fields.Integer('Decimal Digits', states={
'invisible': ~Eval('ttype').in_(['float', 'numeric']),
'required': Eval('ttype').in_(['float', 'numeric']),
})
fields = fields.Function(fields.Many2Many('ir.model.field', None, None,
'Model Fields'), 'on_change_with_fields')
@classmethod
def default_decimal_digits(cls):
return 2
def get_rec_name(self, name):
name = ''
if self.checked:
if self.error:
name = FIRE
else:
name = TICK
name += ' ' + self.name
return name
@classmethod
def __setup__(cls):
super().__setup__()
cls._buttons.update({
'check': {},
})
@classmethod
def __register__(cls, module_name):
super(Expression, cls).__register__(module_name)
cursor = Transaction().connection.cursor()
sql_table = cls.__table__()
# Migration from int to integer
cursor.execute(*sql_table.update([Column(sql_table, 'ttype')],
['integer'], where=sql_table.ttype == 'int'))
# Migration from bool to boolean
cursor.execute(*sql_table.update([Column(sql_table, 'ttype')],
['boolean'], where=sql_table.ttype == 'bool'))
@classmethod
def write(cls, *args):
args = [x.copy() for x in args]
actions = iter(args)
for filters, values in zip(actions, actions):
if 'expression' in values:
values.setdefault('checked', False)
values.setdefault('error')
super().write(*args)
@depends('model')
def on_change_with_fields(self, name=None):
if not self.model:
return []
return [x.id for x in self.model.fields]
@classmethod
@ModelView.button
def check(cls, expressions=None):
pool = Pool()
if not expressions:
expressions = cls.search([], order=[('model', 'ASC')])
else:
# Ensure the right order
expressions = cls.search([('id', 'in', expressions)],
order=[('model', 'ASC')])
count = 0
for key, group in itertools.groupby(expressions, key=lambda x: x.model):
Model = pool.get(key.model)
group = list(group)
count += len(list(group))
records = Model.search([], limit=100, order=[('id', 'ASC')])
records += Model.search([], limit=100, order=[('id', 'DESC')])
for expression in group:
expression.error = None
expression.checked = False
start = time.time()
for record in records:
expression.checked = True
try:
babi_eval(expression.expression, record)
except Exception as e:
expression.error = str(e)
break
# We will not spend more than five seconds to test an
# expression. We do not want to wait unlimitedly for
# complex expressions
if time.time() - start > 5:
logger.info('Waited too much for expression: %s',
expression.expression)
break
expression.save()
class Report(DeactivableMixin, ModelSQL, ModelView):
"Report"
__name__ = 'babi.report'
_history = True
name = fields.Char('Name', required=True, translate=True,
help='New virtual model name.')
model = fields.Many2One('ir.model', 'Model', required=True,
domain=[('babi_enabled', '=', True)],
states={'readonly': pysonOr(
Bool(Eval('dimensions', [0])),
Bool(Eval('columns', [0])),
Bool(Eval('measures', [0]))
),
},
help='Model for data extraction')
model_name = fields.Function(fields.Char('Model Name'),
'on_change_with_model_name')
internal_name = fields.Function(fields.Char('Internal Name', states={
'invisible': Not(Eval('context', {}).get('groups', []
).contains(Id('babi', 'group_babi_admin'))),
}),
'get_internal_name')
filter = fields.Many2One('babi.filter', 'Filter',
domain=[('model', '=', Eval('model'))], depends=['model'])
dimensions = fields.One2Many('babi.dimension', 'report',
'Dimensions')
columns = fields.One2Many('babi.dimension.column', 'report',
'Dimensions on Columns')
measures = fields.One2Many('babi.measure', 'report', 'Measures')
order = fields.One2Many('babi.order', 'report', 'Order', order=[
('sequence', 'ASC')
])
groups = fields.Many2Many('babi.report-res.group', 'report', 'group',
'Groups', help='User groups that will be able to see use this report.')
parent_menu = fields.Many2One('ir.ui.menu', 'Parent Menu',
required=True)
menus = fields.One2Many('ir.ui.menu', 'babi_report', 'Menus',
readonly=True,
states={
'invisible': Not(Eval('context', {}).get('groups', []).contains(
Id('babi', 'group_babi_admin'))),
})
actions = fields.One2Many('ir.action.act_window', 'babi_report',
'Actions', readonly=True,
states={
'invisible': Not(Eval('context', {}).get('groups', []).contains(
Id('babi', 'group_babi_admin'))),
})
keywords = fields.One2Many('ir.action.keyword', 'babi_report', 'Keywords',
readonly=True,
states={
'invisible': Not(Eval('context', {}).get('groups', []).contains(
Id('babi', 'group_babi_admin'))),
})
timeout = fields.Integer('Timeout', required=True, help='If report '
'calculation should take more than the specified timeout (in seconds) '
'the process will be stopped automatically.')
executions = fields.One2Many('babi.report.execution', 'report',
'Executions', readonly=True, order=[('date', 'DESC')],
states={
'invisible': Not(Eval('context', {}).get('groups', []).contains(
Id('babi', 'group_babi_admin'))),
})
crons = fields.One2Many('ir.cron', 'babi_report', 'Schedulers',
domain=[
('method', '=', 'babi.report|calculate_babi_report')
])
report_cell_level = fields.Integer('Cell Level',
help='Start cell level that not has indentation')
email = fields.Boolean('E-mail',
help='Mark to see the options to send an email when '
'executing the cron')
to = fields.Char('To', states={
'invisible': Bool(~Eval('email')),
'required': Bool(Eval('email')),
}, depends=['email'])
subject = fields.Char('Subject', states={
'invisible': Bool(~Eval('email')),
'required': Bool(Eval('email')),
}, depends=['email'])
smtp = fields.Many2One('smtp.server', 'SMTP', states={
'invisible': Bool(~Eval('email')),
'required': Bool(Eval('email')),
}, depends=['email'])
babi_raise_user_error = fields.Boolean('Raise User Error',
help='Will raise a UserError in case of error in report.')
@classmethod
def __setup__(cls):
super(Report, cls).__setup__()
cls._buttons.update({
'calculate': {},
'create_menus': {},
'remove_menus': {},
})
@staticmethod
def default_timeout():
Config = Pool().get('babi.configuration')
config = Config(1)
return config.default_timeout or 30
@staticmethod
def default_report_cell_level():
Config = Pool().get('babi.configuration')
config = Config(1)
return config.report_cell_level or 3
@depends('model')
def on_change_with_model_name(self, name=None):
return self.model.model if self.model else None
def get_internal_name(self, name):
return 'babi_report_%d' % self.id
@classmethod
def write(cls, *args):
actions = iter(args)
Warning = Pool().get('res.user.warning')
for reports, values in zip(actions, actions):
if 'name' in values:
for report in reports:
key = 'report_modification_warning_%s' % report.id
if report.name != values['name'] and Warning.check(key):
raise UserWarning(key,
gettext('babi.report_modification_warning',
report=report.name))
return super(Report, cls).write(*args)
@classmethod
def delete(cls, reports):
with Transaction().set_user(0), \
Transaction().set_context(_check_access=False,
user=0,
babi_order_force=True):
cls.remove_menus(reports)
cls.remove_crons(reports)
super(Report, cls).delete(reports)
@classmethod
def copy(cls, reports, default=None):
if default is None:
default = {}
default = default.copy()
if 'order' not in default:
default['order'] = None
default['actions'] = None
default['keywords'] = None
default['menus'] = None
default['executions'] = None
if 'name' not in default:
result = []
for report in reports:
default['name'] = '%s (2)' % report.name
result.extend(super(Report, cls).copy([report], default))
return result
return super(Report, cls).copy(reports, default)
@classmethod
def remove_crons(cls, reports):
pool = Pool()
Cron = pool.get('ir.cron')
Cron.delete([c for r in reports for c in r.crons])
@classmethod
@ModelView.button
def remove_menus(cls, reports):
"Remove all menus and actions created"
pool = Pool()
ActWindow = pool.get('ir.action.act_window')
Menu = pool.get('ir.ui.menu')
actions = []
menus = []
for report in reports:
actions += report.actions
menus += report.menus
ActWindow.delete(actions)
Menu.delete(menus)
def create_tree_view_menu(self, langs):
pool = Pool()
ActWindow = pool.get('ir.action.act_window')
Action = pool.get('ir.action.wizard')
Menu = pool.get('ir.ui.menu')
ModelData = pool.get('ir.model.data')
encoder = PYSONEncoder()
# This action is needed for the wizard to open the data
action = ActWindow()
action.name = self.name
action.res_model = 'babi.report'
action.domain = encoder.encode([('parent', '=', None)])
action.babi_report = self
action.groups = self.groups
action.context = encoder.encode({'babi_tree_view': True})
action.save()
wizard = Action(ModelData.get_id('babi', 'open_execution_wizard'))
menu = Menu()
menu.name = self.name
menu.parent = self.parent_menu
menu.babi_report = self
menu.action = str(wizard)
menu.icon = 'tryton-tree'
menu.groups = self.groups
menu.babi_type = 'tree'
menu.sequence = 100
menu.save()
if langs:
for lang in langs:
with Transaction().set_context(language=lang.code,
fuzzy_translation=False):
data, = self.read([self.id], fields_names=['name'])
Menu.write([menu], {'name': data['name']})
return menu.id
def create_list_view_menu(self, parent, langs):
"Create list view and action to open"
pool = Pool()
ActWindow = pool.get('ir.action.act_window')
Action = pool.get('ir.action.wizard')
ModelData = pool.get('ir.model.data')
Menu = pool.get('ir.ui.menu')
# This action is needed for the wizard to open the data
action = ActWindow()
action.name = self.name
action.res_model = 'babi.report'
action.babi_report = self
action.groups = self.groups
action.save()
wizard = Action(ModelData.get_id('babi', 'open_execution_wizard'))
menu = Menu()
menu.name = self.name
menu.parent = parent
menu.babi_report = self
menu.action = str(wizard)
menu.icon = 'tryton-list'
menu.groups = self.groups
menu.babi_type = 'list'
menu.save()
if langs:
for lang in langs:
with Transaction().set_context(language=lang.code,
fuzzy_translation=False):
data, = self.read([self.id], fields_names=['name'])
Menu.write([menu], {'name': data['name']})
return menu.id
def create_update_wizard_menu(self, parent):
pool = Pool()
Menu = pool.get('ir.ui.menu')
Action = pool.get('ir.action.wizard')
ModelData = pool.get('ir.model.data')
action = Action(ModelData.get_id('babi', 'open_execution_wizard'))
menu = Menu(ModelData.get_id('babi', 'menu_update_data'))
menu, = Menu.copy([menu], {
'parent': parent,
'babi_report': self.id,
'icon': 'tryton-launch',
'groups': [x.id for x in self.groups],
'babi_type': 'wizard',
'active': True,
})
menu.action = str(action)
menu.save()
def create_history_menu(self, parent):
pool = Pool()
Action = pool.get('ir.action.wizard')
ModelData = pool.get('ir.model.data')
Menu = pool.get('ir.ui.menu')
action = Action(ModelData.get_id('babi', 'open_execution_wizard'))
menu = Menu(ModelData.get_id('babi', 'menu_historical_data'))
menu, = Menu.copy([menu], {
'parent': parent,
'babi_report': self.id,
'icon': 'tryton-launch',
'groups': [x.id for x in self.groups],
'babi_type': 'history',
'active': True,
})
menu.action = str(action)
menu.save()
@classmethod
@ModelView.button
def create_menus(cls, reports):
"""Regenerates all actions and menu entries"""
pool = Pool()
Lang = pool.get('ir.lang')
langs = Lang.search([
('translatable', '=', True),
])
cls.remove_menus(reports)
for report in reports:
menu = report.create_tree_view_menu(langs)
report.create_list_view_menu(menu, langs)
report.create_update_wizard_menu(menu)
report.create_history_menu(menu)
return 'reload menu'
def get_dimensions(self, with_columns=False):
dimensions = []
for dimension in self.dimensions:
dimensions.append(dimension.get_dimension_data())
if with_columns:
for dimension in self.columns:
dimensions.append(dimension.get_dimension_data())
return dimensions
def get_execution_data(self):
return {
'report': self.id,
'timeout': self.timeout,
'company': Transaction().context.get('company'),
}
def compute(self):
'''
Creates an execution, calculates it and sends e-mail if necessary.
'''
pool = Pool()
Execution = pool.get('babi.report.execution')
HTMLReport = pool.get('babi.report.html_report', type='report')
if not self.measures:
raise UserError(gettext('babi.no_measures',
report=self.rec_name))
if not self.dimensions:
raise UserError(gettext('babi.no_dimensions',
report=self.rec_name))
execution, = Execution.create([self.get_execution_data()])
Transaction().commit()
Execution.calculate([execution])
if execution.report.email:
Model = Pool().get(execution.internal_name)
records = Model.search([('parent', '=', None)])
data = {
'model_name': execution.internal_name,
'report_name': execution.report.name,
'records': [x.id for x in records],
'cell_level': execution.report.report_cell_level or 3,
}
report = HTMLReport.execute(records, data)
if report and report[1]:
msg = MIMEMultipart()
msg['To'] = execution.report.to
msg['From'] = execution.report.smtp.smtp_email
msg['Subject'] = Header(execution.report.subject, 'utf-8').encode()
msg.attach(MIMEText('Business Intelligence', 'plain'))
part = MIMEBase('application', 'octet-stream')
part.set_payload(report[1])
encoders.encode_base64(part)
part.add_header('Content-Disposition',
'attachment; filename=report.pdf')
msg.attach(part)
try:
server = execution.report.smtp
to_addrs = [a for _, a in
getaddresses([execution.report.to])]
server.send_mail(msg['From'], to_addrs, msg.as_string())
logger.info('Send email report: %s'
% execution.report.rec_name)
except Exception as exception:
logger.error(
'Unable to delivery email report: %s:\n %s' % (
execution.report.rec_name, exception))
return execution
@classmethod
@ModelView.button
def calculate(cls, reports):
with Transaction().set_context(queue_name=QUEUE_NAME):
for report in reports:
cls.__queue__.compute(report)
class ReportExecution(ModelSQL, ModelView):
"Report Execution"
__name__ = 'babi.report.execution'
report = fields.Many2One('babi.report', 'Report', required=True,
readonly=True, ondelete='CASCADE')
date = fields.DateTime('Execution Date', required=True, readonly=True)
internal_name = fields.Function(fields.Char('Internal Name'),
'get_internal_name')
report_model = fields.Function(fields.Many2One('ir.model', 'Report Model'),
'on_change_with_report_model')
babi_model = fields.Many2One('ir.model', 'BI Model', readonly=True,
help='Link to new model instance')
state = fields.Selection([
('pending', 'Pending'),
('in_progress', 'In progress'),
('calculated', 'Calculated'),
('timeout', 'Timeout'),
('failed', 'Failed'),
('cancelled', "Cancelled"),
], 'State', required=True, readonly=True)
timeout = fields.Integer('Timeout', required=True, readonly=True,
help='If report calculation should take more than the specified '
'timeout (in seconds) the process will be stopped automatically.')
duration = fields.Float('Duration', readonly=True,
help='Number of seconds the calculation took.')
filtered = fields.Boolean('Filtered', help='Used to mark executions with '
'parameter filters evaluated', readonly=True)
filter_values = fields.Text('Filter Values', readonly=True)
internal_measures = fields.One2Many('babi.internal.measure',
'execution', 'Internal Measures', readonly=True)
pid = fields.Integer('Pid', readonly=True)
company = fields.Many2One('company.company', 'Company', required=True,
domain=[
('id', If(Eval('context', {}).contains('company'), '=', '!='),
Eval('context', {}).get('company', -1)),
])
traceback = fields.Text('Traceback', readonly=True)
@classmethod
def __setup__(cls):
super(ReportExecution, cls).__setup__()
cls._order.insert(0, ('date', 'DESC'))
cls._buttons.update({
'open': {
'invisible': Eval('state') != 'calculated',
},
'cancel': {
'invisible': ((Eval('state') != 'in_progress') &
~Eval('pid', False)),
},
})
@classmethod
def __register__(cls, module_name):
cursor = Transaction().connection.cursor()
sql_table = cls.__table__()
super(ReportExecution, cls).__register__(module_name)
# Migration from 5.6: rename state canceled to cancelled
cursor.execute(*sql_table.update(
[sql_table.state], ['cancelled'],
where=sql_table.state == 'canceled'))
@staticmethod
def default_date():
return datetime.now()
@staticmethod
def default_state():
return 'pending'
@staticmethod
def default_filtered():
return False
@staticmethod
def default_timeout():
Config = Pool().get('babi.configuration')
config = Config(1)
return config.default_timeout
@staticmethod
def default_company():
return Transaction().context.get('company')
def get_rec_name(self, name):
Company = Pool().get('company.company')
company_id = Transaction().context.get('company')
date = self.date
if company_id:
company = Company(company_id)
if company.timezone:
timezone = pytz.timezone(company.timezone)
date = timezone.localize(self.date)
date = self.date + date.utcoffset()
return '%s (%s)' % (self.report.rec_name, date)
def get_internal_name(self, name):
return 'babi_execution_%d' % self.id
def get_measures(self):
measures = []
for measure in self.internal_measures:
measures.append(measure.get_measure_data())
return measures
def get_orders(self):
order = []
with Transaction().set_context(_datetime=self.date):
for record in self.report.order:
if record.dimension:
field = record.dimension.internal_name
order.append((field, record.order))
else:
for measure in record.measure.internal_measures:
if measure.execution == self:
field = measure.internal_name
order.append((field, record.order))
return order
@depends('_parent_report.id', 'report')
def on_change_with_report_model(self, name=None):
if self.report:
return self.report.model.id
@classmethod
@ModelView.button_action('babi.open_execution_wizard')
def open(cls, executions):
pass
@classmethod
@ModelView.button
def cancel(cls, executions):
for execution in executions:
if execution.state != 'in_progress':
continue
if not execution.pid:
continue
os.kill(execution.pid, 15)
execution.state = 'cancelled'
execution.save()
@classmethod
def delete(cls, executions):
Model = Pool().get('ir.model')
Model.delete([x.babi_model for x in executions if x.babi_model])
cls.remove_data(executions)
cls.remove_keywords(executions)
to_delete = set([e.internal_name for e in executions])
super(ReportExecution, cls).delete(executions)
# We should remove the classes from the pool so when removing related
# records it doesn't fail checking unexisting models
pool = Pool()
with pool.lock:
for name in to_delete:
try:
del pool._pool[pool.database_name]['model'][name]
except KeyError:
# The model may not be registered on the pool
continue
@classmethod
def remove_keywords(cls, executions):
pool = Pool()
Keyword = pool.get('ir.action.keyword')
models = ['%s,-1' % e.babi_model.model for e in executions
if e.babi_model]
keywords = Keyword.search([('model', 'in', models)])
Keyword.delete(keywords)
@classmethod
def clean(cls, date=None):
pool = Pool()
Date = pool.get('ir.date')
if date is None:
date = Date.today() - timedelta(days=RETENTION_DAYS)
date = datetime.combine(date, mdatetime.time.min)
executions = cls.search([('date', '<', date)])
if executions:
cls.delete(executions)
executions = cls.search([()])
Keyword = pool.get('ir.action.keyword')
models = ['%s,-1' % e.babi_model.model for e in executions
if e.babi_model]
keywords = Keyword.search([('model', 'not in', models),
('babi_report', '!=', None)])
Keyword.delete(keywords)
return True
@classmethod
def remove_data(cls, executions):
cursor = Transaction().connection.cursor()
# Add a transaction for each 200 executions otherwise locks are not
# released on Postgresql and a exception is raised about too many locks
for sub_executions in grouped_slice(executions, 200):
for execution in sub_executions:
table = execution.internal_name
if not backend.TableHandler.table_exist(table):
continue
# Table and model are the same.
backend.TableHandler.drop_table(table, table)
# There is no method to remove sequence in table
# handler, so we must remove them manually
try:
# SQLite doesn't have sequences
cursor.execute("DROP SEQUENCE IF EXISTS %s_id_seq"
% table)
except:
pass
Transaction().commit()
def validate_model(self, with_columns=False, avoid_registration=False):
"makes model available on Tryton and pool instance"
try:
dimensions = self.report.get_dimensions(with_columns)
except AccessError:
# Do not crash if report no longer exists
# Tryton will call ir.model's clean() which will check if all
# models stored in ir.model table exist and try to remove them if
# they no longer exist.
return
measures = self.get_measures()
model = register_class(self.internal_name, self.report.name,
dimensions, measures, avoid_registration)
if not self.babi_model:
self.babi_model = model
self.save()
create_groups_access(model, self.report.groups)
# Commit transaction to avoid locks
Transaction().commit()
def timeout_exception(self):
raise TimeoutException
@staticmethod
def save_state(execution_id, state, exception=False, traceback=None):
" Save state in a new transaction"
DatabaseOperationalError = backend.DatabaseOperationalError
Transaction().rollback()
with Transaction().new_transaction() as new_transaction:
try:
pool = Pool()
Execution = pool.get('babi.report.execution')
Model = pool.get('ir.model')
execution = Execution(execution_id)
to_write = {'state': state}
if traceback:
to_write['traceback'] = traceback
if state == 'in_progress':
to_write['pid'] = os.getpid()
Execution.write([execution], to_write)
if exception:
Execution.remove_data([execution])
Model.delete([execution.babi_model])
report = execution.report
if state == 'calculated':
notify(gettext('babi.msg_report_successful',
report=report.name))
elif state == 'failed':
notify(gettext('babi.msg_report_failed',
report=report.name))
elif state == 'timeout':
notify(gettext('babi.msg_report_timeout',
report=report.name, seconds=report.timeout))
new_transaction.commit()
except DatabaseOperationalError:
new_transaction.rollback()
@classmethod
def calculate(cls, executions):
transaction = Transaction()
for execution in executions:
execution.save_state(execution.id, 'in_progress')
date = execution.create_date
with transaction.set_context(_datetime=date):
execution.validate_model()
with transaction.set_user(0):
execution.create_keywords()
try:
execution.create_data()
except TimeoutException:
execution.save_state(execution.id, 'timeout',
exception=True)
raise UserError(gettext('babi.timeout_exception'))
except MeasureError as message:
execution.save_state(execution.id, 'failed',
exception=True, traceback=repr(message))
except DimensionError as message:
execution.save_state(execution.id, 'failed',
exception=True, traceback=repr(message))
except InvalidTextRepresentation as message:
execution.save_state(execution.id, 'failed',
exception=True, traceback=repr(message))
except Exception as message:
execution.save_state(execution.id, 'failed',
exception=True, traceback=repr(message))
execution.save()
raise
def replace_parameters(self, expression):
if self.report.filter and self.report.filter.parameters:
if not self.filter_values:
raise UserError(gettext('babi.filter_parameters',
execution=self.rec_name))
filter_data = json.loads(self.filter_values,
object_hook=JSONDecoder())
parameters = dict((p.id, p.name) for p in
self.report.filter.parameters)
values = {}
for key, value in filter_data.items():
filter_name = parameters[int(key.split('_')[-1:][0])]
if not value or filter_name not in expression:
continue
values[filter_name] = value
try:
if '%(' in expression:
expression = expression % values
else:
expression = expression.format(**values)
except KeyError as message:
if self.report.babi_raise_user_error:
raise UserError(
gettext('babi.invalid_parameters',
key=str(message)))
raise
return expression
def get_python_filter(self):
if self.report.filter and self.report.filter.python_expression:
return self.replace_parameters(
self.report.filter.python_expression)
def get_domain_filter(self):
domain = '[]'
if self.report.filter and self.report.filter.domain:
domain = self.report.filter.domain
if '__' in domain:
domain = str(PYSONDecoder().decode(domain))
domain = self.replace_parameters(domain)
# TODO: Use a PYSON domain?
return eval(domain, {
'datetime': mdatetime,
'false': False,
'true': True,
})
def get_context(self):
pool = Pool()
Date = pool.get('ir.date')
if self.report.filter and self.report.filter.context:
context = self.replace_parameters(self.report.filter.context)
ev = EvalWithCompoundTypes(names={}, functions={
'date': lambda x: datetime.strptime(x, '%Y-%m-%d').date(),
'datetime': lambda x: datetime.strptime(x, '%Y-%m-%d %H:%M:%S'),
'today': Date.today(),
})
context = ev.eval(context)
return context
def create_keywords(self):
pool = Pool()
Action = pool.get('ir.action.wizard')
ModelData = pool.get('ir.model.data')
Keyword = pool.get('ir.action.keyword')
action = Action(ModelData.get_id('babi', 'open_chart_wizard'))
keyword = Keyword()
keyword.keyword = 'tree_open'
keyword.model = '%s,-1' % self.babi_model.model
keyword.action = action.action
keyword.babi_report = self.report
keyword.groups = self.report.groups
keyword.save()
def create_data(self):
"Creates data for this execution"
pool = Pool()
Model = pool.get(self.report.model.model)
transaction = Transaction()
cursor = transaction.connection.cursor()
BIModel = pool.get(self.babi_model.model)
checker = TimeoutChecker(self.timeout, self.timeout_exception)
logger.info('Updating Data of report: %s' % self.rec_name)
update_start = time.time()
model = self.report.model.model
if not self.report.measures:
raise UserError(gettext('babi.no_measures', report=self.rec_name))
if not self.report.dimensions:
raise UserError(gettext('babi.no_dimensions',
report=self.rec_name))
start = datetime.today()
self.update_internal_measures()
with_columns = len(self.report.columns) > 0
self.validate_model(with_columns=with_columns)
measure_names = [x.internal_name for x in self.internal_measures]
dimension_names = [x.internal_name for x in self.report.dimensions]
if self.report.columns:
dimension_names.extend([x.internal_name for x in
self.report.columns])
columns = ['create_date', 'create_uid']
columns += dimension_names + measure_names
measure_expressions = [x.expression for x in self.internal_measures]
dimension_expressions = [(x.expression.expression,
'' if x.expression.ttype == 'many2one'
else 'empty') for x in
self.report.dimensions]
if self.report.columns:
dimension_expressions.extend([(x.expression.expression,
'' if x.expression.ttype == 'many2one'
else 'empty') for x in self.report.columns])
uid = transaction.user
python_filter = self.get_python_filter()
table = BIModel._table
if self.report.columns:
table = BIModel._table + '_tmp'
# Save data to a temporally table:
cursor.execute('CREATE TEMP TABLE %s AS SELECT * FROM %s WHERE '
' 0 = 1' % (table, BIModel._table))
# Process records
offset = 2000
index = 0
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
domain = self.get_domain_filter()
with transaction.set_context(**context):
try:
records = Model.search(domain, offset=index * offset,
limit=offset)
except Exception as message:
if self.report.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'
% (model, index * offset, datetime.today() - start))
to_create = '' if bytes == str else b''
# var o it's used on expression!!
# Don't rename var
# chunk = records[index * offset:(index + 1) * offset]
for record in records:
if python_filter:
if not babi_eval(python_filter, record,
convert_none=False):
continue
vals = ['now()', str(uid)]
for x in dimension_expressions:
try:
vals.append(sanitanize(babi_eval(x[0], record,
convert_none=x[1])))
except Exception as message:
notify('Error on dimension: "%s" on report "%s"' % (x,
self.report.name), priority=1)
if self.report.babi_raise_user_error:
raise DimensionError(gettext(
'babi.create_data_exception_dimension',
expression=x[0],
record=record.id,
error=repr(message)))
raise
for x in measure_expressions:
try:
vals.append(sanitanize(babi_eval(x, record,
convert_none='zero')))
except Exception as message:
notify('Error on expression: "%s" on report "%s"' % (x,
self.report.name), priority=1)
if self.report.babi_raise_user_error:
raise MeasureError(gettext(
'babi.create_data_exception_measures',
expression=x,
record=record.id,
error=repr(message)))
raise
record = '|'.join(vals).replace('\n', ' ')
record.replace('\\', '')
record += '\n'
record = record.encode('utf-8')
to_create += record
if to_create:
data = BytesIO(to_create)
if hasattr(cursor, 'copy_from'):
cursor.copy_from(data, table, sep='|', null='',
columns=columns)
data.close()
else:
base_query = 'INSERT INTO %s (' % table
base_query += ','.join([str(x) for x in columns])
base_query += ' ) VALUES '
for line in data.readlines():
if len(line) == 0:
continue
if bytes != str:
line = line.decode('utf-8')
query = base_query + '(now(),'
query += ','.join(["'%s'" % str(x)
for x in line.split('|')[1:]])
query += ')'
cursor.execute(query)
index += 1
with transaction.set_context(**context):
records = Model.search(domain, offset=index * offset,
limit=offset)
if self.report.columns:
distincts = self.distinct_dimension_columns(cursor, table)
self.update_internal_measures(distincts)
self.validate_model()
query = 'INSERT INTO %s ('
query += ','.join([str(x) for x in columns])
query += ',' + ','.join([str(x.internal_name) for x in
self.internal_measures])
query += ') SELECT '
query += ','.join([str(x) for x in columns])
query += ',' + ','.join([str(x.expression) for x in
self.internal_measures])
query += ' FROM %s '
cursor.execute(query % (BIModel._table, table))
cursor.execute('DROP TABLE %s ' % (table))
self.update_measures(checker)
logger.info('Calc all %s records in %s seconds'
% (model, datetime.today() - start))
self.state = 'calculated'
self.duration = time.time() - update_start
self.save()
logger.info('End Update Data of report: %s' % self.rec_name)
def distinct_dimension_columns(self, cursor, tablename):
distincts = {}
for dimension in self.report.columns:
cursor.execute('SELECT %s from %s group by 1 order by 1' % (
dimension.internal_name, tablename))
distincts[dimension.id] = [str(x[0]) for x in
cursor.fetchall()]
return distincts
def update_internal_measures(self, distincts=None):
InternalMeasure = Pool().get('babi.internal.measure')
to_create = []
if distincts is None:
distincts = {}
for key in distincts.keys():
# TODO: Make translatable
distincts[key] = ['(all)'] + sorted(list(distincts[key]))
columns = {}
for column in self.report.columns:
columns[column.id] = column
InternalMeasure.delete(self.internal_measures)
sequence = 0
for measure in self.report.measures:
sequence += 1
related_model_id = None
if measure.expression.ttype == 'many2one':
related_model_id = measure.expression.related_model.id
if distincts:
iterator = DimensionIterator(distincts)
else:
iterator = [None]
for combination in iterator:
name = []
internal_names = []
expression = measure.expression.expression
if combination:
for key, index in combination.items():
dimension = columns[key]
value = distincts[key][index]
name.append(dimension.name + ' ' + value)
internal_names.append(
dimension.internal_name + '_' + unaccent(value))
# Zero will always be the '(all)' entry added above
if index > 0:
expression = ('CASE WHEN "%s" = \'%s\' THEN "%s"'
'END') % (dimension.internal_name,
_replace(value), measure.internal_name)
else:
expression = "%s" % (measure.internal_name)
name.append(measure.name)
internal_names.append(measure.internal_name)
name = '/'.join(name)
# PSQL default column name max characters
if ((len(internal_names) > 1)
and (len('_'.join(internal_names)) >
MAX_BD_COLUMN)):
measure_len = len(measure.internal_name)
max_len = MAX_BD_COLUMN - measure_len
combination_name = internal_names.pop(0)[:max_len]
internal_names.insert(0, combination_name)
to_create.append({
'execution': self.id,
'measure': measure.id,
'sequence': sequence,
'name': name,
'internal_name': '_'.join(internal_names),
'aggregate': measure.aggregate,
'expression': expression,
'ttype': measure.expression.ttype,
'related_model': related_model_id,
'decimal_digits': measure.expression.decimal_digits,
})
if to_create:
InternalMeasure.create(to_create)
def update_measures(self, checker):
# Mapping from types to their null values
types_null = defaultdict(int)
types_null['bool'] = False
types_null['char'] = "''"
def query_inserts(table_name, measures, select_group, group,
extra=None):
"""Inserts a group record"""
transaction = Transaction()
cursor = transaction.connection.cursor()
babi_group = ""
if group:
babi_group = ",MAX('%s') as babi_group" % group
local_measures = measures + babi_group
if extra_data:
local_measures += ", %s" % extra_data
select_query = "SELECT %s FROM %s where babi_group IS NULL" % (
local_measures, table_name)
if select_group:
select_query += " GROUP BY %s" % select_group
if extra_data:
select_query += ", %s" % extra_data
fields = []
for measure in local_measures.split(','):
if ' as ' in measure:
measure = measure.split(' as ')[-1]
measure = measure.replace('"', '').strip()
fields.append(unaccent(measure))
query = "INSERT INTO %s(%s)" % (table_name, ','.join(fields))
if not transaction.database.has_returning():
previous_id = 0
cursor.execute('SELECT MAX(id) FROM %s' % table_name)
row = cursor.fetchone()
if row:
previous_id = row[0] or 0
query += select_query
cursor.execute(query)
cursor.execute('SELECT id from %s WHERE id > %s ' % (
table_name, previous_id))
else:
query += " %s RETURNING id" % select_query
cursor.execute(query)
return [x[0] for x in cursor.fetchall()]
def update_parent(table_name, parent_id, group, group_by,
group_by_types):
sql_query = []
for group_item in group_by:
values = {
'item': group_item,
'def': types_null[group_by_types[group_item]],
'table': table_name,
'parent_id': parent_id,
}
# Values should be coalesce to avoid parent errors when null
group_query = ('Coalesce("%(item)s", %(def)s)=(select '
'Coalesce("%(item)s", %(def)s) from %(table)s '
'where id = %(parent_id)d)') % values
sql_query.append(group_query)
sql_query = ' AND '.join(sql_query)
sql_query[:-5]
query = """
UPDATE """ + table_name + """ set parent=%s
WHERE
parent IS NULL AND
id != %s AND
babi_group = '%s'
""" % (parent_id, parent_id, group)
if sql_query:
query += 'AND %s' % sql_query
cursor.execute(query)
pool = Pool()
BIModel = pool.get(self.babi_model.model)
if not self.internal_measures:
return
group_by_types = dict([(x.internal_name, x.expression.ttype)
for x in self.report.dimensions if x.group_by])
group_by = [x.internal_name for x in self.report.dimensions
if x.group_by]
extra_data = ",".join([x.internal_name for x in self.report.dimensions
if not x.group_by])
table_name = Pool().get(self.babi_model.model)._table
cursor = Transaction().connection.cursor()
group_by_iterator = group_by[:]
aggregate = None
current_group = None
while group_by_iterator:
checker.check()
group = ['"%s"' % x for x in group_by_iterator]
measures = ['%s("%s") as %s' % (
x.aggregate == 'count' and aggregate or x.aggregate,
x.internal_name,
x.internal_name,
) for x in self.internal_measures] + group
measures = ','.join(measures)
group = ','.join(group)
logger.info('SELECT table_name %s, measures %s, groups %s' % (
table_name, measures, group))
child_group = current_group
current_group = group_by[len(group_by_iterator) - 1]
parent_ids = query_inserts(table_name, measures, group,
current_group, extra_data)
if group_by != group_by_iterator:
for parent_id in parent_ids:
update_parent(table_name, parent_id, child_group,
group_by_iterator, group_by_types)
child_group = current_group
group_by_iterator.pop()
extra_data = None
# ROOT
measures = ",".join(['%s("%s") as %s' % (
x.aggregate == 'count' and aggregate or x.aggregate,
x.internal_name, x.internal_name) for x in
self.internal_measures])
group = None
parent_id = query_inserts(table_name, measures, None, None)[0]
# TODO: Translate '(all)'
if group_by_types[group_by[0]] == 'char':
cursor.execute("UPDATE " + table_name + " SET \"" + group_by[0] +
"\"='" + '(all)' + "' WHERE id=%s" % parent_id)
update_parent(table_name, parent_id, child_group, group_by_iterator,
group_by_types)
delete = 'DELETE FROM %s WHERE babi_group IS NULL' % (table_name)
cursor.execute(delete + ' and id != %s ' % parent_id)
# Update parent_left, parent_right
BIModel._rebuild_tree('parent', None, 0)
class OpenExecutionSelect(ModelView):
"Open Report Execution - Select Values"
__name__ = 'babi.report.execution.open.select'
# TODO: Add domain for validating report permisions
report = fields.Many2One('babi.report', 'Report', required=True,
states={
'readonly': Bool(Eval('report_readonly')),
}, depends=['report_readonly'])
execution = fields.Many2One('babi.report.execution', 'Execution',
required=True, domain=[
('report', '=', Eval('report')),
('state', '=', 'calculated'),
],
states={
'readonly': Bool(Eval('execution_readonly')),
}, depends=['report', 'execution_readonly'])
view_type = fields.Selection([
('tree', 'Tree'),
('list', 'List'),
], 'View type', required=True)
report_readonly = fields.Boolean('Report Readonly')
execution_readonly = fields.Boolean('Execution Readonly')
@classmethod
def default_get(cls, fields, with_rec_name=True):
pool = Pool()
Execution = pool.get('babi.report.execution')
Menu = pool.get('ir.ui.menu')
result = super(OpenExecutionSelect, cls).default_get(fields,
with_rec_name)
active_id = Transaction().context.get('active_id')
model_name = Transaction().context.get('active_model')
if model_name == 'babi.report.execution':
execution = Execution(active_id)
result.update({
'execution': execution.id,
'report': execution.report.id,
'view_type': 'tree',
'report_readonly': True,
'execution_readonly': True,
})
elif model_name == 'ir.ui.menu':
menu = Menu(active_id)
result.update({
'report': menu.babi_report.id,
'view_type': 'tree',
'report_readonly': True,
})
if menu.babi_type == 'filtered':
result.update({
'execution_readonly': True,
})
return result
@depends('report')
def on_change_report(self):
if not self.report:
self.execution = None
class OpenExecutionFiltered(StateView):
def __init__(self):
buttons = [
Button('Cancel', 'end', 'tryton-cancel'),
Button('Open', 'create_execution', 'tryton-ok', True),
]
super(OpenExecutionFiltered, self).__init__('babi.report', 0, buttons)
def get_view(self, wizard, state_name):
pool = Pool()
Menu = pool.get('ir.ui.menu')
Report = pool.get('babi.report')
Execution = pool.get('babi.report.execution')
Parameter = pool.get('babi.filter.parameter')
context = Transaction().context
model = context.get('active_model')
execution_definitions = Execution.fields_get(fields_names=[
'report', 'report_model'])
report_definition = execution_definitions['report']
report_definition['required'] = True
result = {}
result['type'] = 'form'
result['view_id'] = None
result['model'] = 'babi.report.execution'
result['field_childs'] = None
fields = {}
parameter2report = {}
if model == 'ir.ui.menu':
menu = Menu(context.get('active_id'))
filter = menu.babi_report.filter
report_definition['readonly'] = True
parameters = Parameter.search([('filter', '=', filter)])
else:
# TODO: Report definition add domain for groups
parameters = Parameter.search([
('related_model.model', '=',
context.get('active_model'))],
)
report_definition['readonly'] = False
reports = Report.search([('filter', 'in',
[p.filter for p in parameters])])
for report in reports:
key = report.filter.id
if key in parameter2report:
parameter2report[key].append(report.id)
else:
parameter2report[key] = [report.id]
report_definition['domain'] = ['id', 'in', [r.id for r in reports]]
parameters_to_remove = []
for parameter in parameters:
if not parameter.check_parameter_in_filter():
parameters_to_remove.append(parameter)
for parameter in parameters_to_remove:
parameters.remove(parameter)
xml = '<form string="Generate Filtered Report">\n'
xml += '<label name="report"/>\n'
xml += '<field name="report" colspan="3"/>\n'
fields['report'] = report_definition
encoder = PYSONEncoder()
xml += '<group id="filters" string="Filters" colspan="4">\n'
for parameter in parameters:
# The wizard breaks with non unicode data
name = 'filter_parameter_%d' % parameter.id
field_definition = {
'loading': 'eager',
'name': name,
'string': parameter.name,
'searchable': True,
'create': True,
'help': '',
'context': {},
'delete': True,
'type': parameter.ttype,
'select': False,
'readonly': False,
'required': True,
}
if parameter.ttype in ['many2one', 'many2many']:
field_definition['relation'] = parameter.related_model.model
if parameter2report:
field_definition['states'] = {
'invisible': Not(In(Eval('report', 0),
parameter2report[parameter.filter.id])),
'required': In(Eval('report', 0),
parameter2report[parameter.filter.id]),
}
else:
field_definition['states'] = {}
# Copied from Model.fields_get
for attr in ('states', 'domain', 'context', 'digits', 'size',
'add_remove', 'format'):
if attr in field_definition:
field_definition[attr] = encoder.encode(
field_definition[attr])
if parameter.ttype == 'many2many':
xml += '<field name="%s" colspan="4"/>\n' % (name)
else:
xml += '<label name="%s"/>\n' % (name)
xml += '<field name="%s" colspan="3"/>\n' % (name)
fields[name] = field_definition
xml += '</group>\n'
xml += '</form>\n'
result['arch'] = xml
result['fields'] = fields
return result
def get_defaults(self, wizard, state_name, fields):
pool = Pool()
Menu = pool.get('ir.ui.menu')
Parameter = pool.get('babi.filter.parameter')
context = Transaction().context
model = context.get('active_model')
defaults = {}
if model == 'ir.ui.menu':
menu = Menu(context.get('active_id'))
defaults['report'] = menu.babi_report.id
else:
parameters = Parameter.search([
('related_model.model', '=', model),
])
for parameter in parameters:
name = '%s_%d' % (parameter.name, parameter.id)
defaults[name] = context.get('active_id')
return defaults
class CustomDict(dict):
def __getattr__(self, name):
return {}
def __setattr__(self, name, value):
self[name] = value
class UpdateDataWizardStart(ModelView):
"Update Data Wizard Start"
__name__ = 'babi.update_data.wizard.start'
class UpdateDataWizardUpdated(ModelView):
"Update Data Wizard Done"
__name__ = 'babi.update_data.wizard.done'
class OpenExecution(Wizard):
'Open Report Execution'
__name__ = 'babi.report.execution.open'
start = StateTransition()
update_start = StateView('babi.update_data.wizard.start',
'babi.update_data_wizard_start_form_view', [
Button('Cancel', 'end', 'tryton-cancel'),
Button('Ok', 'update', 'tryton-ok', default=True),
])
filtered = OpenExecutionFiltered()
create_execution = StateTransition()
select = StateView('babi.report.execution.open.select',
'babi.open_execution_select_view_form', [
Button('Cancel', 'end', 'tryton-cancel'),
Button('Open', 'open_view', 'tryton-ok', True),
])
open_view = StateAction('babi.open_execution_wizard')
update = StateTransition()
update_done = StateView('babi.update_data.wizard.done',
'babi.update_data_wizard_done_form_view', [
Button('Ok', 'end', 'tryton-ok', default=True),
])
def __getattribute__(self, name):
if name == 'filtered':
if not hasattr(self, 'filter_values'):
self.filter_values = CustomDict()
name = 'filter_values'
return super(OpenExecution, self).__getattribute__(name)
def transition_start(self):
pool = Pool()
Menu = pool.get('ir.ui.menu')
context = Transaction().context
model_name = context.get('active_model')
if model_name == 'babi.report.execution':
return 'select'
elif model_name == 'ir.ui.menu':
menu = Menu(context.get('active_id'))
if menu.babi_report.filter and \
len(menu.babi_report.filter.parameters) > 0:
return 'filtered'
if menu.babi_type == 'history':
return 'select'
if menu.babi_type == 'wizard':
return 'update_start'
return 'open_view'
else:
return 'filtered'
def transition_create_execution(self):
pool = Pool()
Report = pool.get('babi.report')
Execution = pool.get('babi.report.execution')
report = self.filter_values.pop('report', None)
if not report:
raise UserError(gettext('babi.no_report_found'))
data = {}
for key, value in self.filter_values.items():
# Fields has id of the field appendend, so it must be removed.
new_key = '_'.join(key.split('_')[:-1])
data[new_key] = value
report = Report(report)
execution = report.get_execution_data()
data = json.dumps(self.filter_values, cls=JSONEncoder)
execution['filter_values'] = data
execution['filtered'] = True
execution, = Execution.create([execution])
Transaction().commit()
Execution.calculate([execution])
context = Transaction().context
context.update({
'filtered_execution': execution.id,
})
return 'open_view'
def transition_update(self):
pool = Pool()
Menu = pool.get('ir.ui.menu')
Report = pool.get('babi.report')
menu = Menu(Transaction().context['active_id'])
Report.calculate([menu.babi_report])
return 'update_done'
def do_open_view(self, action):
pool = Pool()
Action = pool.get('ir.action')
ActionWindow = pool.get('ir.action.act_window')
Menu = pool.get('ir.ui.menu')
Execution = pool.get('babi.report.execution')
transaction = Transaction()
context = transaction.context
model_name = context.get('active_model')
if model_name == 'ir.ui.menu':
menu = Menu(context.get('active_id'))
if menu.babi_type == 'history' and hasattr(self.select, 'report'):
report = self.select.report
execution = self.select.execution
view_type = self.select.view_type
else:
report = menu.babi_report
if 'filtered_execution' in context:
execution = Execution(context.get('filtered_execution'))
else:
company = transaction.context.get('company')
executions = Execution.search([
('report', '=', report.id),
('state', '=', 'calculated'),
('filtered', '=', False),
('company', '=', company),
], limit=1, order=[('create_date', 'DESC')])
execution = executions[0] if executions else None
view_type = menu.babi_type
else:
report = self.select.report
execution = self.select.execution
view_type = self.select.view_type
if not execution:
raise UserError(gettext('babi.no_execution',
report=report.rec_name))
with transaction.set_context(_datetime=execution.date):
execution.validate_model()
domain = [
('babi_report', '=', report.id),
]
if view_type == 'tree':
domain.append(('context', 'ilike', "%%babi_tree_view%%"))
else:
domain.append(('context', 'not ilike', "%%babi_tree_view%%"))
try:
action, = ActionWindow.search(domain, limit=1)
action = Action.get_action_values(action.type, [action.id])[0]
except ValueError:
raise UserError(gettext('babi.no_menus', report=report.rec_name))
action['res_model'] = execution.babi_model.model
action['name'] = execution.rec_name
action['context_model'] = None
return action, {}
class ReportGroup(ModelSQL):
"Report - Group"
__name__ = 'babi.report-res.group'
report = fields.Many2One('babi.report', 'Report', required=True,
ondelete='CASCADE')
group = fields.Many2One('res.group', 'Group', required=True)
@classmethod
def __setup__(cls):
super(ReportGroup, cls).__setup__()
t = cls.__table__()
cls._sql_constraints += [
('report_group_uniq', Unique(t, t.report, t.group),
'Report and Group must be unique.'),
]
class DimensionMixin:
__slots__ = ()
report = fields.Many2One('babi.report', 'Report', required=True,
ondelete='CASCADE')
sequence = fields.Integer('Sequence')
name = fields.Char('Name', required=True, translate=True)
internal_name = fields.Function(fields.Char('Internal Name'),
'get_internal_name')
expression = fields.Many2One('babi.expression', 'Expression',
required=True, domain=[
('model', '=', Eval('_parent_report', {}).get('model', 0)),
], depends=['report'])
group_by = fields.Boolean('Group By This Dimension')
width = fields.Integer('Width',
help='Width report columns (%)')
expand = fields.Integer('Expand',
help="Column expand attribute for tree view")
def get_internal_name(self, name):
return 'babi_dimension_%d' % self.id
@staticmethod
def order_sequence(tables):
table, _ = tables[None]
return [table.sequence == Null, table.sequence]
@staticmethod
def default_group_by():
return True
@depends('expression')
def on_change_with_name(self):
return self.expression.name if self.expression else None
def get_dimension_data(self):
return {
'name': self.name,
'internal_name': self.internal_name,
'expression': self.expression.expression,
'ttype': self.expression.ttype,
'related_model': (self.expression.related_model
and self.expression.related_model.model),
'decimal_digits': self.expression.decimal_digits,
}
class Dimension(ModelSQL, ModelView, DimensionMixin):
"Dimension"
__name__ = 'babi.dimension'
_history = True
@classmethod
def __setup__(cls):
super(Dimension, cls).__setup__()
t = cls.__table__()
cls._order.insert(0, ('sequence', 'ASC'))
cls._sql_constraints += [
('report_and_name_unique', Unique(t, t.report, t.name),
'Dimension name must be unique per report.'),
]
@classmethod
def update_order(cls, dimensions):
Order = Pool().get('babi.order')
cursor = Transaction().connection.cursor()
dimension_ids = [x.id for x in dimensions]
orders = Order.search([
('dimension', 'in', dimension_ids),
])
existing = [x.dimension.id for x in orders]
missing = set(dimension_ids) - set(existing)
to_create = []
for dimension in cls.browse(list(missing)):
cursor.execute('SELECT MAX(sequence) FROM babi_order WHERE '
'report=%s' % dimension.report.id)
sequence = cursor.fetchone()[0] or 0
to_create.append({
'report': dimension.report.id,
'sequence': sequence + 10,
'dimension': dimension.id,
})
with Transaction().set_context({'babi_order_force': True}):
Order.create(to_create)
@classmethod
def create(cls, values):
dimensions = super(Dimension, cls).create(values)
cls.update_order(dimensions)
return dimensions
@classmethod
def write(cls, *args):
actions = iter(args)
to_update = []
for dimensions, _ in zip(actions, actions):
to_update += dimensions
cls.update_order(to_update)
return super(Dimension, cls).write(*args)
@classmethod
def delete(cls, dimensions):
Order = Pool().get('babi.order')
orders = Order.search([
('dimension', 'in', [x.id for x in dimensions]),
])
if orders:
with Transaction().set_context({'babi_order_force': True}):
Order.delete(orders)
return super(Dimension, cls).delete(dimensions)
class DimensionColumn(ModelSQL, ModelView, DimensionMixin):
"Column Dimension"
__name__ = 'babi.dimension.column'
_history = True
@classmethod
def __setup__(cls):
super(DimensionColumn, cls).__setup__()
cls._order.insert(0, ('sequence', 'ASC'))
def get_internal_name(self, name):
return 'babi_dimension_column_%d' % self.id
class Measure(ModelSQL, ModelView):
"Measure"
__name__ = 'babi.measure'
_history = True
report = fields.Many2One('babi.report', 'Report', required=True,
ondelete='CASCADE')
sequence = fields.Integer('Sequence')
name = fields.Char('Name', required=True, translate=True)
internal_name = fields.Function(fields.Char('Internal Name'),
'get_internal_name')
expression = fields.Many2One('babi.expression', 'Expression',
required=True, domain=[
('model', '=', Eval('_parent_report', {}).get('model', 0)),
If(Eval('aggregate').in_(['sum', 'avg']), [
('ttype', 'in', ['integer', 'float', 'numeric']),
],
[])
], depends=['aggregate', 'report'])
aggregate = fields.Selection(AGGREGATE_TYPES, 'Aggregate', required=True)
internal_measures = fields.One2Many('babi.internal.measure',
'measure', 'Internal Measures')
width = fields.Integer('Width',
help='Width report columns (%)')
expand = fields.Integer('Expand',
help="Column expand attribute for tree view")
@classmethod
def __setup__(cls):
super(Measure, cls).__setup__()
t = cls.__table__()
cls._order.insert(0, ('sequence', 'ASC'))
cls._sql_constraints += [
('report_and_name_unique', Unique(t, t.report, t.name),
'Measure name must be unique per report.'),
]
@staticmethod
def order_sequence(tables):
table, _ = tables[None]
return [table.sequence == Null, table.sequence]
@staticmethod
def default_aggregate():
return 'sum'
@depends('expression')
def on_change_with_name(self):
return self.expression.name if self.expression else None
def get_internal_name(self, name):
return 'babi_measure_%d' % (self.id)
def get_measure_data(self):
return {
'name': self.name,
'internal_name': self.internal_name,
'expression': self.expression,
'ttype': self.ttype,
'related_model': (self.related_model and
self.related_model.model),
'decimal_digits': self.expression.decimal_digits,
}
@classmethod
def update_order(cls, measures):
Order = Pool().get('babi.order')
cursor = Transaction().connection.cursor()
measure_ids = [x.id for x in measures]
orders = Order.search([
('measure', 'in', measure_ids),
])
existing_ids = [x.measure.id for x in orders]
missing_ids = set(measure_ids) - set(existing_ids)
to_create = []
for measure in cls.browse(list(missing_ids)):
cursor.execute('SELECT MAX(sequence) FROM babi_order WHERE '
'report=%s' % measure.report.id)
sequence = cursor.fetchone()[0] or 0
to_create.append({
'report': measure.report.id,
'sequence': sequence + 1,
'measure': measure.id,
})
with Transaction().set_context({'babi_order_force': True}):
Order.create(to_create)
@classmethod
def create(cls, values):
measures = super(Measure, cls).create(values)
cls.update_order(measures)
return measures
@classmethod
def write(cls, *args):
actions = iter(args)
to_update = []
for measures, _ in zip(actions, actions):
to_update += measures
cls.update_order(to_update)
return super(Measure, cls).write(*args)
@classmethod
def delete(cls, measures):
Order = Pool().get('babi.order')
to_remove = []
for measure in measures:
orders = Order.search([
('measure', '=', measure.id),
])
to_remove += orders
if to_remove:
with Transaction().set_context({'babi_order_force': True}):
Order.delete(to_remove)
return super(Measure, cls).delete(measures)
class InternalMeasure(ModelSQL, ModelView):
"Internal Measure"
__name__ = 'babi.internal.measure'
execution = fields.Many2One('babi.report.execution', 'Report Execution',
required=True, ondelete='CASCADE')
measure = fields.Many2One('babi.measure', 'Measure', required=True,
ondelete='CASCADE')
sequence = fields.Integer('Sequence', required=True)
name = fields.Char('Name', required=True)
internal_name = fields.Char('Internal Name', required=True)
expression = fields.Char('Expression')
aggregate = fields.Selection(AGGREGATE_TYPES, 'Aggregate', required=True)
ttype = fields.Selection(FIELD_TYPES, 'Field Type',
required=True)
related_model = fields.Many2One('ir.model', 'Related Model')
decimal_digits = fields.Integer('Decimal Digits')
width = fields.Integer('Width')
expand = fields.Function(fields.Integer('Expand'), 'get_expand')
def get_expand(self, name):
return self.measure.expand
@classmethod
def __setup__(cls):
super(InternalMeasure, cls).__setup__()
cls._order.insert(0, ('sequence', 'ASC'))
@classmethod
def __register__(cls, module_name):
super(InternalMeasure, cls).__register__(module_name)
cursor = Transaction().connection.cursor()
sql_table = cls.__table__()
# Migration from 3.0: no more relation with reports.
table = backend.TableHandler(cls, module_name)
if table.column_exist('report'):
table.not_null_action('report', action='remove')
# Migration from int to integer
cursor.execute(*sql_table.update([Column(sql_table, 'ttype')],
['integer'], where=sql_table.ttype == 'int'))
# Migration from bool to boolean
cursor.execute(*sql_table.update([Column(sql_table, 'ttype')],
['boolean'], where=sql_table.ttype == 'bool'))
def get_measure_data(self):
return {
'name': self.name,
'internal_name': self.internal_name,
'expression': self.expression,
'ttype': self.ttype,
'related_model': (self.related_model and
self.related_model.model),
'decimal_digits': self.decimal_digits,
}
class Order(ModelSQL, ModelView, sequence_ordered()):
"Order"
__name__ = 'babi.order'
_history = True
report = fields.Many2One('babi.report', 'Report', required=True,
ondelete='CASCADE')
dimension = fields.Many2One('babi.dimension', 'Dimension', readonly=True,
ondelete='CASCADE')
measure = fields.Many2One('babi.measure', 'Measure', readonly=True,
ondelete='CASCADE')
order = fields.Selection([
('ASC', 'Ascending'),
('DESC', 'Descending'),
], 'Order', required=True)
@staticmethod
def default_order():
return 'ASC'
@classmethod
def __setup__(cls):
super(Order, cls).__setup__()
t = cls.__table__()
cls._sql_constraints += [
('report_and_dimension_unique', Unique(t, t.report, t.dimension),
'Dimension must be unique per report.'),
('report_and_measure_unique', Unique(t, t.report, t.measure),
'Measure must be unique per report.'),
('dimension_or_measure', Check(t, Or((
(t.dimension == Null) & (t.measure != Null),
(t.dimension != Null) & (t.measure == Null)
))),
'Only dimension or measure can be set.'),
]
@classmethod
def create(cls, values):
if not Transaction().context.get('babi_order_force'):
raise UserError(gettext('babi.cannot_create_order_entry'))
return super(Order, cls).create(values)
@classmethod
def delete(cls, orders):
if not Transaction().context.get('babi_order_force'):
raise UserError(gettext('babi.cannot_remove_order_entry'))
return super(Order, cls).delete(orders)
class ActWindow(metaclass=PoolMeta):
__name__ = 'ir.action.act_window'
babi_report = fields.Many2One('babi.report', 'BABI Report')
class Menu(metaclass=PoolMeta):
__name__ = 'ir.ui.menu'
babi_report = fields.Many2One('babi.report', 'BABI Report')
babi_type = fields.Selection([
(None, ''),
('tree', 'Tree'),
('list', 'List'),
('history', 'History'),
('wizard', 'Wizard'),
], 'BABI Type', readonly=True)
class Keyword(metaclass=PoolMeta):
__name__ = 'ir.action.keyword'
babi_report = fields.Many2One('babi.report', 'BABI Report')
babi_filter_parameter = fields.Many2One('babi.filter.parameter',
'BABI Filter Parameter')
class Model(metaclass=PoolMeta):
__name__ = 'ir.model'
babi_enabled = fields.Boolean('BI Enabled', help='Check if you want '
'this model to be available in Business Intelligence reports.')
class OpenChartStart(ModelView):
"Open Chart Start"
__name__ = 'babi.open_chart.start'
graph_type = fields.Selection([
('vbar', 'Vertical Bars'),
('hbar', 'Horizontal Bars'),
('line', 'Line'),
('pie', 'Pie'),
('report', 'Report'),
], 'Graph', required=True, sort=False)
interpolation = fields.Selection([
('linear', 'Linear'),
('constant-center', 'Constant Center'),
('constant-left', 'Constant Left'),
('constant-right', 'Constant Right'),
], 'Interpolation',
states={
'required': Eval('graph_type') == 'line',
'invisible': Eval('graph_type') != 'line',
}, depends=['graph_type'], sort=False)
show_legend = fields.Boolean('Show Legend',
states={
'invisible': (Eval('graph_type') == 'report'),
}, depends=['graph_type'])
report = fields.Many2One('babi.report', 'Report',
states={
'invisible': (Eval('graph_type') == 'report'),
}, depends=['graph_type'])
execution = fields.Many2One('babi.report.execution', 'Execution',
states={
'invisible': (Eval('graph_type') == 'report'),
}, depends=['graph_type'])
execution_date = fields.DateTime('Execution Time',
states={
'invisible': (Eval('graph_type') == 'report'),
}, depends=['graph_type'])
dimension = fields.Many2One('babi.dimension', 'Dimension',
domain=[
('report', '=', Eval('report')),
],
context={
'_datetime': Eval('execution_date'),
},
states={
'required': Eval('graph_type') != 'report',
'invisible': Eval('graph_type') == 'report',
}, depends=['report', 'execution_date', 'graph_type'])
measures = fields.Many2Many('babi.internal.measure', None, None,
'Measures', required=True,
domain=[
('execution', '=', Eval('execution')),
],
states={
'required': Eval('graph_type') != 'report',
'invisible': Eval('graph_type') == 'report',
}, depends=['execution', 'graph_type'])
graph_type_report = fields.Selection([
('pdf', 'PDF'),
('html', 'HTML'),
('xls', 'Excel'),
], 'Report Format',
states={
'required': Eval('graph_type') == 'report',
'invisible': Eval('graph_type') != 'report',
}, depends=['graph_type'])
@classmethod
def default_get(cls, fields, with_rec_name=True):
pool = Pool()
Execution = pool.get('babi.report.execution')
model_name = Transaction().context.get('active_model')
executions = Execution.search([
('babi_model.model', '=', model_name),
], limit=1)
result = super(OpenChartStart, cls).default_get(fields, with_rec_name)
if len(executions) != 1:
return result
execution, = executions
report = execution.report
Model = pool.get(model_name)
active_id, = Transaction().context.get('active_ids')
record = Model(active_id)
with Transaction().set_context(_datetime=execution.create_date):
fields = []
found = False
for x in report.dimensions:
if found:
fields.append(x.id)
continue
if x.internal_name == str(record.babi_group):
found = True
if not fields:
# If it was not found it means user clicked on 'root'
# babi_group
fields = [x.id for x in report.dimensions]
return {
'report': execution.report.id,
'execution': execution.id,
'execution_date': execution.date,
'model': execution.babi_model.id,
'dimension': fields[0] if fields else None,
'measures': [x.id for x in execution.internal_measures],
'graph_type': 'vbar',
'show_legend': True,
'interpolation': 'linear',
'graph_type_report': 'pdf',
}
class EmptyStateAction(StateAction):
def __init__(self):
super(EmptyStateAction, self).__init__(None)
def get_action(self):
return {}
class OpenChart(Wizard):
"Open Chart"
__name__ = 'babi.open_chart'
start = StateView('babi.open_chart.start',
'babi.open_chart_start_form_view', [
Button('Cancel', 'end', 'tryton-cancel'),
Button('Print', 'print_', 'tryton-ok',
states={
'invisible': (Eval('graph_type') != 'report'),
},
),
Button('Open', 'open_', 'tryton-ok', default=True,
states={
'invisible': (Eval('graph_type') == 'report'),
},
),
])
open_ = EmptyStateAction()
print_ = StateReport('babi.report.html_report')
def do_open_(self, action):
pool = Pool()
context = Transaction().context
model_name = context.get('active_model')
active_ids = context.get('active_ids')
Model = pool.get(model_name)
if len(self.start.measures) > 1 and self.start.graph_type == 'pie':
raise UserError(gettext('babi.one_measure_in_pie_charts'))
group_name = self.start.dimension.internal_name
records = Model.search([
('babi_group', '=', group_name),
('parent', 'child_of', active_ids),
])
domain = [('id', 'in', [x.id for x in records])]
domain = json.dumps(domain)
context = {}
context['view_type'] = 'graph'
context['graph_type'] = self.start.graph_type
context['dimension'] = self.start.dimension.id
context['measures'] = [x.id for x in self.start.measures]
context['legend'] = self.start.show_legend
context['interpolation'] = self.start.interpolation
context['model_name'] = model_name
context = json.dumps(context)
action = {
'id': -1,
'name': '%s - %s Chart' % (self.start.execution.rec_name,
self.start.dimension.rec_name),
'model': model_name,
'res_model': model_name,
'type': 'ir.action.act_window',
'context_model': None,
'context_domain': None,
'pyson_domain': domain,
'pyson_context': context,
'pyson_order': '[]',
'pyson_search_value': '[]',
'domains': [],
}
return action, {}
def do_print_(self, action):
context = Transaction().context
model_name = context.get('active_model')
active_ids = context.get('active_ids')
report = self.start.report
data = {
'model_name': model_name,
'report_name': report.name,
'records': active_ids,
'cell_level': report.report_cell_level or 3,
'output_format': self.start.graph_type_report or 'pdf',
}
return action, data
def transition_print_(self):
return 'end'
class CleanExecutionsStart(ModelView):
'Clean Execution Start'
__name__ = 'babi.clean_executions.start'
date = fields.Date('Date', required=True)
class CleanExecutions(Wizard):
'Clean Executions'
__name__ = 'babi.clean_executions'
start = StateView('babi.clean_executions.start',
'babi.clean_executions_start_form_view', [
Button('Cancel', 'end', 'tryton-cancel'),
Button('Ok', 'clean', 'tryton-ok', default=True),
])
clean = StateTransition()
def transition_clean(self):
pool = Pool()
Execution = pool.get('babi.report.execution')
Execution.clean(self.start.date)
return 'end'