Browse Source

[IMP][8.0][web_dashboard_tile] Refactor (see changes in description) (#476)

pull/495/head
Iván Todorovich 8 years ago
committed by Pedro M. Baeza
parent
commit
3ab676daac
  1. 29
      web_dashboard_tile/README.rst
  2. 2
      web_dashboard_tile/__openerp__.py
  3. 4
      web_dashboard_tile/demo/tile_tile.yml
  4. 13
      web_dashboard_tile/migrations/8.0.3.0/post-migration.py
  5. 241
      web_dashboard_tile/models/tile_tile.py
  6. 11
      web_dashboard_tile/security/rules.xml
  7. 43
      web_dashboard_tile/static/src/css/tile.css
  8. BIN
      web_dashboard_tile/static/src/img/screenshot_dashboard.png
  9. 6
      web_dashboard_tile/tests/__init__.py
  10. 52
      web_dashboard_tile/tests/test_tile.py
  11. 89
      web_dashboard_tile/views/tile.xml

29
web_dashboard_tile/README.rst

@ -1,17 +1,22 @@
Dashboard Tiles Dashboard Tiles
=============== ===============
Adds a dashboard where you can configure tiles from any view
and add them as short cut.
* Tile can be:
* Displayed only for a user.
* Global for all users (In that case, some tiles will be hidden if
the current user doesn't have access to the given model).
* The tile displays items count of a given model restricted to a given domain.
* Optionally, the tile can display the result of a function of a field
* Function is one of sum/avg/min/max/median.
* Field must be integer or float.
Adds a dashboard where you can configure tiles from any view and add them as short cut.
By default, the tile displays items count of a given model restricted to a given domain.
Optionally, the tile can display the result of a function on a field.
- Function is one of `sum`, `avg`, `min`, `max` or `median`.
- Field must be integer or float.
Tile can be:
- Displayed only for a user.
- Global for all users.
- Restricted to some groups.
*Note: The tile will be hidden if the current user doesn't have access to the given model.*
Usage Usage
===== =====
@ -36,6 +41,8 @@ Roadmap
* Add icons. * Add icons.
* Support client side action (like inbox). * Support client side action (like inbox).
* Restore original Domain + Filter when an action is set. * Restore original Domain + Filter when an action is set.
* Posibility to hide the tile based on a field expression.
* Posibility to set the background color based on a field expression.
Bug Tracker Bug Tracker
=========== ===========

2
web_dashboard_tile/__openerp__.py

@ -5,7 +5,7 @@
{ {
"name": "Dashboard Tile", "name": "Dashboard Tile",
"summary": "Add Tiles to Dashboard", "summary": "Add Tiles to Dashboard",
"version": "8.0.1.1.0",
"version": "8.0.3.0.0",
"depends": [ "depends": [
'web', 'web',
'board', 'board',

4
web_dashboard_tile/demo/tile_tile.yml

@ -36,5 +36,5 @@
name: Currencies (Max Rate) name: Currencies (Max Rate)
model_id: base.model_res_currency model_id: base.model_res_currency
domain: [] domain: []
field_function: max
field_id: base.field_res_currency_rate
secondary_function: max
secondary_field_id: base.field_res_currency_rate

13
web_dashboard_tile/migrations/8.0.3.0/post-migration.py

@ -0,0 +1,13 @@
# -*- coding: utf-8 -*-
# © 2016 Iván Todorovich <ivan.todorovich@gmail.com>
# License AGPL-3 - See http://www.gnu.org/licenses/agpl-3.0.html
def migrate(cr, version):
if version is None:
return
# Rename old fields
cr.execute("""UPDATE tile_tile SET primary_function = 'count'""")
cr.execute("""UPDATE tile_tile SET secondary_function = field_function""")
cr.execute("""UPDATE tile_tile SET secondary_field_id = field_id""")

241
web_dashboard_tile/models/tile_tile.py

@ -7,6 +7,7 @@
import datetime import datetime
import time import time
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from collections import OrderedDict
from openerp import api, fields, models from openerp import api, fields, models
from openerp.tools.safe_eval import safe_eval as eval from openerp.tools.safe_eval import safe_eval as eval
@ -14,18 +15,52 @@ from openerp.tools.translate import _
from openerp.exceptions import ValidationError, except_orm from openerp.exceptions import ValidationError, except_orm
def median(vals):
# https://docs.python.org/3/library/statistics.html#statistics.median
# TODO : refactor, using statistics.median when Odoo will be available
# in Python 3.4
even = (0 if len(vals) % 2 else 1) + 1
half = (len(vals) - 1) / 2
return sum(sorted(vals)[half:half + even]) / float(even)
FIELD_FUNCTIONS = OrderedDict([
('count', {
'name': 'Count',
'func': False, # its hardcoded in _compute_data
'help': _('Number of records')}),
('min', {
'name': 'Minimum',
'func': min,
'help': _("Minimum value of '%s'")}),
('max', {
'name': 'Maximum',
'func': max,
'help': _("Maximum value of '%s'")}),
('sum', {
'name': 'Sum',
'func': sum,
'help': _("Total value of '%s'")}),
('avg', {
'name': 'Average',
'func': lambda vals: sum(vals)/len(vals),
'help': _("Minimum value of '%s'")}),
('median', {
'name': 'Median',
'func': median,
'help': _("Median value of '%s'")}),
])
FIELD_FUNCTION_SELECTION = [
(k, FIELD_FUNCTIONS[k].get('name')) for k in FIELD_FUNCTIONS]
class TileTile(models.Model): class TileTile(models.Model):
_name = 'tile.tile' _name = 'tile.tile'
_description = 'Dashboard Tile'
_order = 'sequence, name' _order = 'sequence, name'
def median(self, aList):
# https://docs.python.org/3/library/statistics.html#statistics.median
# TODO : refactor, using statistics.median when Odoo will be available
# in Python 3.4
even = (0 if len(aList) % 2 else 1) + 1
half = (len(aList) - 1) / 2
return sum(sorted(aList)[half:half + even]) / float(even)
def _get_eval_context(self): def _get_eval_context(self):
def _context_today(): def _context_today():
return fields.Date.from_string(fields.Date.context_today(self)) return fields.Date.from_string(fields.Date.context_today(self))
@ -46,70 +81,127 @@ class TileTile(models.Model):
background_color = fields.Char(default='#0E6C7E', oldname='color') background_color = fields.Char(default='#0E6C7E', oldname='color')
font_color = fields.Char(default='#FFFFFF') font_color = fields.Char(default='#FFFFFF')
group_ids = fields.Many2many(
'res.groups',
string='Groups',
help='If this field is set, only users of this group can view this '
'tile. Please note that it will only work for global tiles '
'(that is, when User field is left empty)')
model_id = fields.Many2one('ir.model', 'Model', required=True) model_id = fields.Many2one('ir.model', 'Model', required=True)
domain = fields.Text(default='[]') domain = fields.Text(default='[]')
action_id = fields.Many2one('ir.actions.act_window', 'Action') action_id = fields.Many2one('ir.actions.act_window', 'Action')
count = fields.Integer(compute='_compute_data')
computed_value = fields.Float(compute='_compute_data')
field_function = fields.Selection([
('min', 'Minimum'),
('max', 'Maximum'),
('sum', 'Sum'),
('avg', 'Average'),
('median', 'Median'),
], string='Function')
field_id = fields.Many2one(
'ir.model.fields',
string='Field',
domain="[('model_id', '=', model_id),"
" ('ttype', 'in', ['float', 'int'])]")
helper = fields.Char(compute='_compute_function_helper')
active = fields.Boolean( active = fields.Boolean(
compute='_compute_active', compute='_compute_active',
search='_search_active', search='_search_active',
readonly=True) readonly=True)
# Primary Value
primary_function = fields.Selection(
FIELD_FUNCTION_SELECTION,
string='Function',
default='count')
primary_field_id = fields.Many2one(
'ir.model.fields',
string='Field',
domain="[('model_id', '=', model_id),"
" ('ttype', 'in', ['float', 'integer'])]")
primary_format = fields.Char(
string='Format',
help='Python Format String valid with str.format()\n'
'ie: \'{:,} Kgs\' will output \'1,000 Kgs\' if value is 1000.')
primary_value = fields.Char(
string='Value',
compute='_compute_data')
primary_helper = fields.Char(
string='Helper',
compute='_compute_helper')
# Secondary Value
secondary_function = fields.Selection(
FIELD_FUNCTION_SELECTION,
string='Secondary Function')
secondary_field_id = fields.Many2one(
'ir.model.fields',
string='Secondary Field',
domain="[('model_id', '=', model_id),"
" ('ttype', 'in', ['float', 'integer'])]")
secondary_format = fields.Char(
string='Secondary Format',
help='Python Format String valid with str.format()\n'
'ie: \'{:,} Kgs\' will output \'1,000 Kgs\' if value is 1000.')
secondary_value = fields.Char(
string='Secondary Value',
compute='_compute_data')
secondary_helper = fields.Char(
string='Secondary Helper',
compute='_compute_helper')
error = fields.Char(
string='Error Details',
compute='_compute_data')
@api.one @api.one
def _compute_data(self): def _compute_data(self):
self.count = 0
self.computed_value = 0
if self.active:
# Compute count item
model = self.env[self.model_id.model]
eval_context = self._get_eval_context()
self.count = model.search_count(eval(self.domain, eval_context))
# Compute datas for field_id depending of field_function
if self.field_function and self.field_id and self.count != 0:
records = model.search(eval(self.domain, eval_context))
vals = [x[self.field_id.name] for x in records]
if self.field_function == 'min':
self.computed_value = min(vals)
elif self.field_function == 'max':
self.computed_value = max(vals)
elif self.field_function == 'sum':
self.computed_value = sum(vals)
elif self.field_function == 'avg':
self.computed_value = sum(vals) / len(vals)
elif self.field_function == 'median':
self.computed_value = self.median(vals)
if not self.active:
return
model = self.env[self.model_id.model]
eval_context = self._get_eval_context()
domain = self.domain or '[]'
try:
count = model.search_count(eval(domain, eval_context))
except Exception as e:
self.primary_value = self.secondary_value = 'ERR!'
self.error = str(e)
return
if any([
self.primary_function and
self.primary_function != 'count',
self.secondary_function and
self.secondary_function != 'count'
]):
records = model.search(eval(domain, eval_context))
for f in ['primary_', 'secondary_']:
f_function = f+'function'
f_field_id = f+'field_id'
f_format = f+'format'
f_value = f+'value'
value = 0
if self[f_function] == 'count':
value = count
elif self[f_function]:
func = FIELD_FUNCTIONS[self[f_function]]['func']
if func and self[f_field_id] and count:
vals = [x[self[f_field_id].name] for x in records]
value = func(vals)
if self[f_function]:
try:
self[f_value] = (self[f_format] or '{:,}').format(value)
except ValueError as e:
self[f_value] = 'F_ERR!'
self.error = str(e)
return
else:
self[f_value] = False
@api.one @api.one
@api.onchange('field_function', 'field_id')
def _compute_function_helper(self):
self.helper = ''
if self.field_function and self.field_id:
desc = self.field_id.field_description
helpers = {
'min': "Minimum value of '%s'",
'max': "Maximum value of '%s'",
'sum': "Total value of '%s'",
'avg': "Average value of '%s'",
'median': "Median value of '%s'",
}
self.helper = _(helpers.get(self.field_function, '')) % desc
@api.onchange('primary_function', 'primary_field_id',
'secondary_function', 'secondary_field_id')
def _compute_helper(self):
for f in ['primary_', 'secondary_']:
f_function = f+'function'
f_field_id = f+'field_id'
f_helper = f+'helper'
self[f_helper] = ''
field_func = FIELD_FUNCTIONS.get(self[f_function], {})
help = field_func.get('help', False)
if help:
if self[f_function] != 'count' and self[f_field_id]:
desc = self[f_field_id].field_description
self[f_helper] = help % desc
else:
self[f_helper] = help
@api.one @api.one
def _compute_active(self): def _compute_active(self):
@ -135,19 +227,28 @@ class TileTile(models.Model):
# Constraints and onchanges # Constraints and onchanges
@api.one @api.one
@api.constrains('model_id', 'field_id')
@api.constrains('model_id', 'primary_field_id', 'secondary_field_id')
def _check_model_id_field_id(self): def _check_model_id_field_id(self):
if self.field_id and self.field_id.model_id.id != self.model_id.id:
raise ValidationError(
_("Please select a field from the selected model."))
if any([
self.primary_field_id and
self.primary_field_id.model_id.id != self.model_id.id,
self.secondary_field_id and
self.secondary_field_id.model_id.id != self.model_id.id
]):
raise ValidationError(
_("Please select a field from the selected model."))
@api.one
@api.constrains('field_id', 'field_function')
def _check_field_id_field_function(self):
validations = self.field_id, self.field_function
if any(validations) and not all(validations):
raise ValidationError(
_("Please set both: 'Field' and 'Function'."))
@api.onchange('model_id')
def _onchange_model_id(self):
self.primary_field_id = False
self.secondary_field_id = False
@api.onchange('primary_function', 'secondary_function')
def _onchange_function(self):
if self.primary_function in [False, 'count']:
self.primary_field_id = False
if self.secondary_function in [False, 'count']:
self.secondary_field_id = False
# Action methods # Action methods
@api.multi @api.multi

11
web_dashboard_tile/security/rules.xml

@ -6,7 +6,16 @@
<field name="name">tile.owner</field> <field name="name">tile.owner</field>
<field name="model_id" ref="model_tile_tile" /> <field name="model_id" ref="model_tile_tile" />
<field name="groups" eval="[(4, ref('base.group_user'))]"/> <field name="groups" eval="[(4, ref('base.group_user'))]"/>
<field name="domain_force">[('user_id','in',[False,user.id])]</field>
<field name="domain_force">
[
'|',
('user_id','=',user.id),
('user_id','=',False),
'|',
('group_ids','=',False),
('group_ids','in',[g.id for g in user.groups_id]),
]
</field>
</record> </record>
</data> </data>

43
web_dashboard_tile/static/src/css/tile.css

@ -5,44 +5,51 @@
border-radius: 0; border-radius: 0;
} }
/* Disable default kanban style */
.openerp .oe_kanban_view .oe_dashboard_tile .oe_kanban_content div:first-child {
margin-right: inherit!important;
}
.openerp .oe_kanban_view .oe_dashboard_tile .tile_label, .openerp .oe_kanban_view .oe_dashboard_tile .tile_label,
.openerp .oe_kanban_view .oe_dashboard_tile .tile_count_without_computed_value,
.openerp .oe_kanban_view .oe_dashboard_tile .tile_count_with_computed_value,
.openerp .oe_kanban_view .oe_dashboard_tile .tile_computed_value {
width: 140px;
.openerp .oe_kanban_view .oe_dashboard_tile .tile_primary_value,
.openerp .oe_kanban_view .oe_dashboard_tile .tile_secondary_value {
text-align: center; text-align: center;
font-weight: bold;
} }
.openerp .oe_kanban_view .oe_dashboard_tile .tile_label{
.openerp .oe_kanban_view .oe_dashboard_tile .tile_label {
padding: 5px; padding: 5px;
font-size: 15px; font-size: 15px;
} }
.openerp .oe_kanban_view .oe_dashboard_tile .tile_count_without_computed_value{
font-size: 52px;
font-weight: bold;
.openerp .oe_kanban_view .oe_dashboard_tile .tile_primary_value{
font-size: 54px;
position: absolute; position: absolute;
left: 5px; left: 5px;
right: 5px;
bottom: 5px; bottom: 5px;
} }
.openerp .oe_kanban_view .oe_dashboard_tile .tile_count_with_computed_value{
font-size: 38px;
font-weight: bold;
.openerp .oe_kanban_view .oe_dashboard_tile .tile_secondary_value{
display: none;
font-size: 18px;
font-style: italic;
position: absolute; position: absolute;
left: 5px; left: 5px;
right: 5px;
bottom: 5px;
}
.openerp .oe_kanban_view .oe_dashboard_tile .with_secondary .tile_primary_value{
font-size: 38px;
bottom: 30px; bottom: 30px;
} }
.openerp .oe_kanban_view .oe_dashboard_tile .tile_computed_value{
font-size: 18px;
font-weight: bold;
position: absolute;
right: 10px;
bottom: 5px;
font-style: italic;
.openerp .oe_kanban_view .oe_dashboard_tile .with_secondary .tile_secondary_value{
display: block;
} }
/* SearchView Drawer */
.openerp .oe_searchview_drawer .oe_searchview_dashboard .oe_dashboard_tile_form { .openerp .oe_searchview_drawer .oe_searchview_dashboard .oe_dashboard_tile_form {
display: none; display: none;
} }

BIN
web_dashboard_tile/static/src/img/screenshot_dashboard.png

Before

Width: 796  |  Height: 162  |  Size: 45 KiB

After

Width: 771  |  Height: 155  |  Size: 20 KiB

6
web_dashboard_tile/tests/__init__.py

@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
# © 2016 Antonio Espinosa - <antonio.espinosa@tecnativa.com>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
# flake8: noqa
from . import test_tile

52
web_dashboard_tile/tests/test_tile.py

@ -0,0 +1,52 @@
# -*- coding: utf-8 -*-
# © 2016 Antonio Espinosa - <antonio.espinosa@tecnativa.com>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from openerp.tests.common import TransactionCase
class TestTile(TransactionCase):
def test_tile(self):
tile_obj = self.env['tile.tile']
model_id = self.env['ir.model'].search([
('model', '=', 'tile.tile')])
field_id = self.env['ir.model.fields'].search([
('model_id', '=', model_id.id),
('name', '=', 'sequence')])
self.tile1 = tile_obj.create({
'name': 'Count / Sum',
'sequence': 1,
'model_id': model_id.id,
'domain': "[('model_id', '=', %d)]" % model_id.id,
'secondary_function': 'sum',
'secondary_field_id': field_id.id})
self.tile2 = tile_obj.create({
'name': 'Min / Max',
'sequence': 2,
'model_id': model_id.id,
'domain': "[('model_id', '=', %d)]" % model_id.id,
'primary_function': 'min',
'primary_field_id': field_id.id,
'secondary_function': 'max',
'secondary_field_id': field_id.id})
self.tile3 = tile_obj.create({
'name': 'Avg / Median',
'sequence': 3,
'model_id': model_id.id,
'domain': "[('model_id', '=', %d)]" % model_id.id,
'primary_function': 'avg',
'primary_field_id': field_id.id,
'secondary_function': 'median',
'secondary_field_id': field_id.id})
# count
self.assertEqual(self.tile1.primary_value, '3')
# sum
self.assertEqual(self.tile1.secondary_value, '6')
# min
self.assertEqual(self.tile2.primary_value, '1')
# max
self.assertEqual(self.tile2.secondary_value, '3')
# average
self.assertEqual(self.tile3.primary_value, '2')
# median
self.assertEqual(self.tile3.secondary_value, '2.0')

89
web_dashboard_tile/views/tile.xml

@ -9,8 +9,10 @@
<field name="name"/> <field name="name"/>
<field name="domain"/> <field name="domain"/>
<field name="model_id"/> <field name="model_id"/>
<field name="field_function"/>
<field name="field_id"/>
<field name="primary_function"/>
<field name="primary_field_id"/>
<field name="secondary_function"/>
<field name="secondary_field_id"/>
<field name="user_id"/> <field name="user_id"/>
<field name="background_color" widget="color"/> <field name="background_color" widget="color"/>
</tree> </tree>
@ -34,11 +36,50 @@
<field name="model_id"/> <field name="model_id"/>
<field name="action_id"/> <field name="action_id"/>
<field name="domain" colspan="4"/> <field name="domain" colspan="4"/>
<separator string="Optional Field Informations" colspan="4"/>
<field name="field_function"/>
<field name="field_id"/>
<field name="helper" colspan="4"/>
<separator colspan="4"/>
<field name="error" attrs="{'invisible':[('error','=',False)]}"/>
</group> </group>
<notebook>
<page string="Main Value">
<group>
<group>
<field name="primary_function"/>
<field name="primary_field_id" attrs="{
'invisible':[('primary_function','in',[False,'count'])],
'required':[('primary_function','not in',[False,'count'])],
}"/>
</group>
<group>
<field name="primary_format"/>
</group>
<group>
<field name="primary_helper"/>
<field name="primary_value" attrs="{'invisible':[('primary_value','=',False)]}"/>
</group>
</group>
</page>
<page string="Secondary Value">
<group>
<group>
<field name="secondary_function"/>
<field name="secondary_field_id" attrs="{
'invisible':[('secondary_function','in',[False,'count'])],
'required':[('secondary_function','not in',[False,'count'])],
}"/>
</group>
<group>
<field name="secondary_format"/>
</group>
<group>
<field name="secondary_helper"/>
<field name="secondary_value" attrs="{'invisible':[('secondary_value','=',False)]}"/>
</group>
</group>
</page>
<page string="Groups">
<field name="group_ids"/>
</page>
</notebook>
</sheet> </sheet>
</form> </form>
</field> </field>
@ -53,37 +94,31 @@
<field name="domain"/> <field name="domain"/>
<field name="model_id"/> <field name="model_id"/>
<field name="action_id"/> <field name="action_id"/>
<field name="count"/>
<field name="background_color"/> <field name="background_color"/>
<field name="font_color"/> <field name="font_color"/>
<field name="field_id" />
<field name="field_function" />
<field name="helper" />
<field name="primary_function"/>
<field name="primary_helper"/>
<field name="secondary_function"/>
<field name="secondary_helper"/>
<templates> <templates>
<t t-name="kanban-box"> <t t-name="kanban-box">
<div t-attf-class="oe_dashboard_tile oe_kanban_global_click" t-attf-style="background-color:#{record.background_color.raw_value}" > <div t-attf-class="oe_dashboard_tile oe_kanban_global_click" t-attf-style="background-color:#{record.background_color.raw_value}" >
<div class="oe_kanban_content"> <div class="oe_kanban_content">
<a type="object" name="open_link" args="[]" t-attf-style="color:#{record.font_color.raw_value};"> <a type="object" name="open_link" args="[]" t-attf-style="color:#{record.font_color.raw_value};">
<div class="tile_label">
<b><field name="name"/></b>
</div>
<div style="padding-left: 0.5em; height: 115px;">
</div>
<t t-if="record.field_id.raw_value != '' and record.field_function.raw_value != '' and record.count.raw_value !=0">
<div class="tile_count_with_computed_value">
<span><field name="count"/></span>
<div style="height:100%;" t-att-class="record.secondary_function.raw_value and 'with_secondary' or 'simple'">
<div class="tile_label">
<field name="name"/>
</div> </div>
<div class="tile_computed_value" t-att-title="record.helper.raw_value">
<img t-att-src="_s + '/web_dashboard_tile/static/src/img/' + record.field_function.raw_value + '.png'"/>
<span><field name="computed_value"/></span>
<div class="tile_primary_value" t-att-title="record.primary_helper.raw_value">
<t t-set="l" t-value="record.primary_value.raw_value.length" />
<t t-set="s" t-value="l>=12 and 35 or l>=10 and 45 or l>=8 and 55 or l>=6 and 75 or l>4 and 85 or 100"/>
<span t-attf-style="font-size: #{s}%;"><field name="primary_value"/></span>
</div> </div>
</t>
<t t-if="!(record.field_id.raw_value != '' and record.field_function.raw_value != '' and record.count.raw_value !=0)">
<div class="tile_count_without_computed_value">
<span><field name="count"/></span>
<div class="tile_secondary_value" t-att-title="record.secondary_helper.raw_value">
<span><field name="secondary_value"/></span>
</div> </div>
</t>
</div>
</a> </a>
</div> </div>
<div class="oe_clear"></div> <div class="oe_clear"></div>

Loading…
Cancel
Save