This commit is contained in:
2025-08-19 15:05:55 +07:00
parent 70ae66ee4b
commit f78dc34aee
161 changed files with 13703 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
# Quickboard
> [!WARNING]
> This module is purely experimental and for educational purpose use only.
>
> Do not use it in any environment but in an experimental one, definitely not in a production environment.
>
> I'm not responsible for any damage or harm by the use of anything from this repo.
>
> Use it at your own risk.
> [!CAUTION]
> AI might generate commands that negatively impact your data.
>
> Do not use this module unless you have reviewed the source codes thoroughly, understand what it does and in an experimental environment.
This module demonstrate how to create simple yet flexible dashboard and how to use AI to generate dashboard items and to arrange the dashboard layout.
Please watch this video for more details:
[![EXPLORING_ODOO](https://img.youtube.com/vi/LfxlUN9pikI/0.jpg)](https://youtu.be/LfxlUN9pikI)
[![EXPLORING_ODOO](https://img.youtube.com/vi/y_prYVEp9mk/0.jpg)](https://youtu.be/y_prYVEp9mk)
+3
View File
@@ -0,0 +1,3 @@
from . import controllers
from . import models
from . import wizard
+36
View File
@@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
{
'name': "Quickboard",
'summary': """Quickboard is a simple and easy to use dashboard powered with AI.""",
'description': """
Quickboard is a simple and easy to use dashboard powered with AI.
""",
'author': "Yoni Tjio",
'category': 'Productivity',
'version': '18.0.1.0.0',
'depends': ['web'],
'data': [
'security/quickboard_security.xml',
'security/ir.model.access.csv',
'views/quickboard_views.xml',
'views/quickboard_item_views.xml',
'wizard/quickboard_generator_views.xml'
],
'assets': {
"web.assets_backend": [
"quickboard/static/src/**/*",
("remove", "quickboard/static/src/quickboard/**/*")
],
"quickboard.assets": [
('include', "web.chartjs_lib"),
"quickboard/static/lib/gridstack/*",
"quickboard/static/lib/spinjs/*",
"quickboard/static/src/quickboard/**/*",
"quickboard/static/src/css/**/*",
],
},
"license":"Other proprietary",
"application": True,
"installable": True,
"auto_install": False
}
+2
View File
@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import main
+297
View File
@@ -0,0 +1,297 @@
# -*- coding: utf-8 -*-
from ast import literal_eval
import pandas as pd
from odoo import http, fields, models
from odoo.http import request
from odoo.osv import expression
from odoo.tools import DEFAULT_SERVER_DATE_FORMAT
class QuickboardController(http.Controller):
def get_quickboard_item_values(self, quickboard_item, start_date=None, end_date=None, with_data=False):
vals = {
'id': quickboard_item.id,
'name': quickboard_item.name,
'model_name': quickboard_item.model_name,
'icon': quickboard_item.icon,
'type': quickboard_item.type,
'chart_type': quickboard_item.chart_type,
'height': quickboard_item.height,
'width': quickboard_item.width,
'x_pos': quickboard_item.x_pos,
'y_pos': quickboard_item.y_pos,
'value_field_name': ",".join([f"{o.display_name}" for o in quickboard_item.value_field_id]),
'value_field_type': ",".join([f"{o.ttype}" for o in quickboard_item.value_field_id]),
'dimension_field_name': quickboard_item.dimension_field_id.display_name,
'dimension_field_type': quickboard_item.dimension_field_id.ttype,
'datetime_granularity': quickboard_item.datetime_granularity,
'group_field_name': quickboard_item.group_field_id.display_name,
'group_field_type': quickboard_item.group_field_id.ttype,
'list_row_limit': quickboard_item.list_row_limit,
'aggregate_function': quickboard_item.aggregate_function,
'text_color': quickboard_item.text_color,
'background_color': quickboard_item.background_color
}
if with_data:
domain = []
date_filter_field = "create_date"
if date_filter_field not in request.env[quickboard_item.model_name]:
field_iterable = request.env[quickboard_item.model_name]._fields.items()
new_date_filter_field = next((v for k, v in field_iterable if v.type in ["date", "datetime"]), None)
date_filter_field = new_date_filter_field.name
if start_date and date_filter_field is not None:
sd = fields.Datetime.from_string(start_date)
domain.append((date_filter_field, ">", sd))
if end_date and date_filter_field is not None:
ed = fields.Datetime.from_string(end_date)
domain.append((date_filter_field, "<", ed))
if quickboard_item.domain_filter and quickboard_item.domain_filter != "":
the_filter = expression.AND([literal_eval(quickboard_item.domain_filter)])
domain = expression.AND([domain, the_filter])
if quickboard_item.type == "basic":
aggregate_value = 0
aggr_func = f"{quickboard_item.value_field_id.name}:{quickboard_item.aggregate_function}"
agg = request.env[quickboard_item.model_name].sudo()._read_group(
domain=domain,
groupby=[],
aggregates=[aggr_func]
)
aggregate_value = agg[0][0] if agg[0][0] else 0
vals.update({ 'aggregate_value': aggregate_value })
elif quickboard_item.type == 'list':
data = []
grouping = []
aggr_func = f"{quickboard_item.value_field_id.name}:{quickboard_item.aggregate_function}"
group_by = quickboard_item.dimension_field_id.name
if quickboard_item.dimension_field_id.ttype in ["date", "datetime"]:
group_by = f"{group_by}:{quickboard_item.datetime_granularity}"
grouping.append(group_by)
limit = quickboard_item.list_row_limit
order = f"{aggr_func} desc"
aggs = request.env[quickboard_item.model_name].sudo()._read_group(
domain=domain,
groupby=grouping,
aggregates=[aggr_func],
limit=limit,
order=order
)
# seq is to ease t-foreach on the javascript part because it needs t-key
for seq, agg in enumerate(aggs, start=1):
if isinstance(agg[0], models.Model):
if agg[0]:
x_data = agg[0].name
else:
x_data = "N/A"
else:
x_data = agg[0]
data.append({
"seq": seq,
"x": x_data,
"y": agg[1]
})
vals.update({'data': data})
else:
if quickboard_item.group_field_id:
data = []
grouping = []
aggr_func = f"{quickboard_item.value_field_id.name}:{quickboard_item.aggregate_function}"
group_by = quickboard_item.dimension_field_id.name
if quickboard_item.dimension_field_id.ttype in ["date", "datetime"]:
group_by = f"{group_by}:{quickboard_item.datetime_granularity}"
grouping.append(group_by)
order = f"{grouping[0]} desc, {aggr_func} asc"
sub_group = quickboard_item.group_field_id.name
if quickboard_item.group_field_id.ttype in "many2one":
sub_group = quickboard_item.group_field_id.name
if quickboard_item.group_field_id.ttype in ["date", "datetime"]:
sub_group = f"{sub_group}:{quickboard_item.datetime_granularity}"
grouping.append(sub_group)
order = f"{grouping[1]} desc, {grouping[0]} desc, {aggr_func} asc"
aggs = request.env[quickboard_item.model_name].sudo()._read_group(
domain=domain,
groupby=grouping,
aggregates=[aggr_func],
order=order
)
if len(aggs) > 0:
dimension_field_display_name = quickboard_item.dimension_field_id.display_name
group_field_display_name = quickboard_item.group_field_id.display_name
value_field_display_name = quickboard_item.value_field_id.display_name
df = pd.DataFrame(aggs,
columns=[
dimension_field_display_name,
group_field_display_name,
value_field_display_name
]
)
if (quickboard_item.dimension_field_id.ttype == "many2one"):
df[dimension_field_display_name] = df[dimension_field_display_name].map(lambda o: o.name)
filler = None
if quickboard_item.dimension_field_id.ttype in ["date", "datetime"]:
if quickboard_item.datetime_granularity == "year":
filler = pd.DataFrame(pd.date_range(
df[dimension_field_display_name].min(),
df[dimension_field_display_name].max(),
freq="YS"
),
columns=[dimension_field_display_name]
)
elif quickboard_item.datetime_granularity == "month":
filler = pd.DataFrame(pd.date_range(
df[dimension_field_display_name].min(),
df[dimension_field_display_name].max(),
freq="MS"
),
columns=[dimension_field_display_name]
)
else:
filler = pd.DataFrame(pd.date_range(
df[dimension_field_display_name].min(),
df[dimension_field_display_name].max(),
freq="D"
),
columns=[dimension_field_display_name]
)
else:
filler = pd.DataFrame(
df[dimension_field_display_name].unique(),
columns=[dimension_field_display_name]
)
keys = df[group_field_display_name].unique().tolist()
for key in keys:
dfd = df.loc[
df[group_field_display_name] == key
][
[
dimension_field_display_name,
value_field_display_name
]
]
dfd = filler.merge(right=dfd, how="left", on=dimension_field_display_name)
if (quickboard_item.value_field_id.ttype in ["integer", "float", "monetary"]):
dfd = dfd.fillna(0)
else:
dfd = dfd.fillna("")
if quickboard_item.dimension_field_id.ttype in ["date", "datetime"]:
dfd[dimension_field_display_name] = dfd[dimension_field_display_name].dt.strftime(DEFAULT_SERVER_DATE_FORMAT)
dataset = dfd.values.tolist()
label = "N/A"
if isinstance(key, models.Model):
label = key.name
else:
label = key
data.append({
"label": label,
"dataset": dataset
})
vals.update({'data': data})
else:
data = []
group_by = quickboard_item.dimension_field_id.name
if quickboard_item.dimension_field_id.ttype in ["date", "datetime"]:
group_by = f"{group_by}:{quickboard_item.datetime_granularity}"
for value_field in quickboard_item.value_field_id:
aggr_func = f"{value_field.name}:{quickboard_item.aggregate_function}"
order = f"{group_by} desc, {aggr_func} asc"
aggs = request.env[quickboard_item.model_name].sudo()._read_group(
domain=domain,
groupby=[group_by],
aggregates=[aggr_func],
order=order
)
dataset = []
for agg in aggs:
if isinstance(agg[0], models.Model):
if agg[0]:
x_data = agg[0].name
else:
x_data = "N/A"
else:
x_data = agg[0]
dataset.append([x_data, agg[1]])
data.append({
"label": value_field.display_name,
"dataset": dataset
})
vals.update({'data': data})
return vals
@http.route('/quickboard/item', type='json', auth='user', website=True)
def get_quickboard_item(self, item_id, start_date=None, end_date=None):
quickboard_item = request.env['quickboard.item'].with_context({"hide_model": True}).search([("id", "=", item_id)])
vals = self.get_quickboard_item_values(quickboard_item, start_date, end_date, True)
return vals
@http.route('/quickboard/item_defs', type='json', auth='user', website=True)
def get_quickboard_items(self):
items = []
for quickboard_item in request.env['quickboard.item'].with_context({"hide_model": True}).search([], order="id"):
vals = self.get_quickboard_item_values(quickboard_item, None, None, False)
items.append(vals)
return items
@http.route('/quickboard/save_layout', type='json', auth='user', website=True)
def save_layout(self, layout):
for item in layout:
quickboard_item = request.env["quickboard.item"].with_context({"hide_model": True}).search([("id", "=", item["id"])], limit=1)
quickboard_item.update({
"x_pos": item["x"],
"y_pos": item["y"],
"height": item["h"] if "h" in item else 0,
"width": item["w"] if "w" in item else 0
})
return True
@http.route('/quickboard/save_theme', type='json', auth='user', website=True)
def save_theme(self, theme):
request.env.user.res_users_settings_id.quickboard_theme = theme
return True
@http.route('/quickboard/save_filter', type='json', auth='user', website=True)
def save_filter(self, start_date, end_date):
request.env.user.res_users_settings_id.update({
"quickboard_start_date": start_date,
"quickboard_end_date": end_date
})
return True
+3
View File
@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from . import quickboard_item
from . import res_users_settings
+162
View File
@@ -0,0 +1,162 @@
# -*- coding: utf-8 -*-
from typing import Dict, List
from odoo import api, fields, models
from odoo.exceptions import ValidationError
class QuickboardItem(models.Model):
_name = "quickboard.item"
_description = "Quickboard Item"
name = fields.Char(string="Name")
model_id = fields.Many2one('ir.model', string='Model')
model_name = fields.Char(related='model_id.model', string="Model Name")
icon = fields.Char(string="Icon")
type = fields.Selection(
selection=[("basic", "Basic"), ("chart", "Chart"), ("list", "List")],
string="Item Type",
default="basic")
chart_type = fields.Selection(
selection=[("bar", "Bar"), ('horizontal-bar', 'Horizontal Bar'), ('doughnut', "Doughnut"), ("line", "Line"), ("pie", "Pie"), ("polar", "Polar Area")],
string="Chart Type")
value_field_id = fields.Many2many("ir.model.fields", string="Value Field")
aggregate_function = fields.Selection(
selection=[("avg","Average"), ("count", "Count"), ('max', "Max"), ('min', "Min"), ("sum","Sum")],
string="Aggregate Function",
default="count",
depends=['value_field_id'])
dimension_field_id = fields.Many2one("ir.model.fields", string="Dimension Field")
group_field_id = fields.Many2one("ir.model.fields", string="Group Field")
datetime_granularity = fields.Selection(
selection=[("year", "Year"), ("month", "Month"), ("day", "Day")],
string="Date/Time Granularity",
default="day",
depends=['dimension_field_id'])
list_row_limit = fields.Integer(string="Row limit", default=10)
domain_filter = fields.Char(string="Filter")
# basic item color
text_color = fields.Integer("Text Color")
background_color = fields.Integer("Background Color")
# layout
x_pos = fields.Integer(string="X Pos")
y_pos = fields.Integer(string="Y Pos")
height = fields.Integer(string="Height")
width = fields.Integer(string="Width")
@api.model_create_multi
def create(self, vals_list):
for val in vals_list:
if not self.env.context.get("ai_generation", False):
if 'type' in val:
if val["type"] == "basic":
val["width"] = 2
val["height"] = 1
else:
val["width"] = 4
val["height"] = 2
sql = f"""WITH item_dim AS (
SELECT y_pos, CASE WHEN height = 0 THEN 1 ELSE height END AS height
FROM quickboard_item sdi WHERE create_uid = {self.env.uid}
)
SELECT max(y_pos + height) as max_y_pos FROM item_dim WHERE y_pos = (SELECT max(y_pos) FROM item_dim);
"""
self.env.cr.execute(sql)
res = self.env.cr.dictfetchall()
max_y_pos = res[0].get("max_y_pos")
val["y_pos"] = max_y_pos
val["x_pos"] = 0
res = super().create(vals_list)
return res
@api.onchange("type")
def clear_values(self):
for rec in self:
if rec.type:
if rec.type == 'basic':
rec.group_field_id = False
rec.dimension_field_id = False
elif rec.type == 'list':
rec.group_field_id = False
@api.constrains("aggregate_function", "value_field_id")
def _validate_aggregate_function(self):
for rec in self:
if rec.type != 'chart' and len(rec.value_field_id) > 1:
raise ValidationError(f"Basic and list items can only have one value field.")
if rec.type == 'chart':
for vf in rec.value_field_id:
if vf.ttype not in ['float', 'integer', 'monetary'] and rec.aggregate_function != "count":
raise ValidationError(f"Other fields than float, integer and monetary can only use count as aggregation.")
else:
if rec.value_field_id.ttype not in ['float', 'integer', 'monetary'] and rec.aggregate_function != "count":
raise ValidationError(f"Other fields than float, integer and monetary can only use count as aggregation.")
@api.constrains("value_field_id", "dimension_field_id")
def _validate_value_field_01(self):
for rec in self:
if rec.value_field_id and rec.dimension_field_id and rec.value_field_id == rec.dimension_field_id:
raise ValidationError("Value field must not be the same with dimension field.")
@api.constrains("dimension_field_id", "type")
def _validate_dimension_field_01(self):
for rec in self:
if rec.type in ["chart", "list"] and not rec.dimension_field_id:
raise ValidationError("Dimension field is required for charts.")
@api.constrains("list_row_limit", "type")
def _validate_dimension_field_02(self):
for rec in self:
if rec.type == "list" and not rec.list_row_limit:
raise ValidationError("Row limit is required for lists.")
@api.constrains("dimension_field_id", "datetime_granularity")
def _validate_dimension_field_03(self):
for rec in self:
if rec.dimension_field_id.ttype in ["date", "datetime"] and not rec.datetime_granularity:
raise ValidationError("Granularity for date or datetime field is required for charts.")
@api.constrains("dimension_field_id", "group_field_id")
def _validate_dimension_field_04(self):
for rec in self:
if not rec.dimension_field_id and rec.group_field_id:
raise ValidationError("Dimension field is required for grouping data.")
if rec.dimension_field_id and rec.group_field_id and rec.dimension_field_id == rec.group_field_id:
raise ValidationError("Dimension field must not be the same with grouping field.")
@api.constrains("group_field_id", "type")
def _validate_group_field_01(self):
for rec in self:
if rec.type != "chart" and rec.group_field_id:
raise ValidationError("Grouping only supported for charts.")
@api.constrains("group_field_id")
def _validate_group_field_02(self):
for rec in self:
if rec.group_field_id and rec.group_field_id.ttype in ["many2many", "one2many"]:
raise ValidationError("Grouping is not supported for x2many fields.")
@api.constrains("value_field_id", "group_field_id")
def _validate_group_field_03(self):
for rec in self:
if rec.group_field_id and len(rec.value_field_id) > 1:
raise ValidationError("Grouping is not supported when using multiple value fields.")
def web_save(self, vals, specification: Dict[str, Dict], next_id=None) -> List[Dict]:
res = super(QuickboardItem, self).web_save(vals, specification=specification, next_id=next_id)
if self.env.context.get("quick_edit", False):
self.env["bus.bus"]._sendone(
"quickboard",
"quickboard_item_updated",
{
"id": self.id,
}
)
return res
+30
View File
@@ -0,0 +1,30 @@
# -*- coding: utf-8 -*-
from odoo import api, fields, models
from odoo.exceptions import ValidationError
class Users(models.Model):
_inherit = 'res.users.settings'
quickboard_theme = fields.Char("Quickboard Theme", default="def")
quickboard_start_date = fields.Char("Start Date")
quickboard_end_date = fields.Char("End Date")
@api.constrains("quickboard_start_date")
def _validate_start_date(self):
for rec in self:
if rec.quickboard_start_date and rec.quickboard_start_date.strip() != "":
dt = fields.Datetime.from_string(rec.quickboard_start_date)
if not dt:
raise ValidationError(f"Invalid date.")
else:
rec.quickboard_start_date = None
@api.constrains("quickboard_end_date")
def _validate_end_date(self):
for rec in self:
if rec.quickboard_end_date and rec.quickboard_end_date.strip() != "":
dt = fields.Datetime.from_string(rec.quickboard_end_date)
if not dt:
raise ValidationError(f"Invalid date.")
else:
rec.quickboard_end_date = None
+5
View File
@@ -0,0 +1,5 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_ir_model_quick_board,access_ir_model_quick_board,base.model_ir_model,group_quickboard_user,1,0,0,0
access_ir_field_quick_board,access_ir_field_quick_board,base.model_ir_model_fields,group_quickboard_user,1,0,0,0
access_quickboard_item,access_quickboard_item,model_quickboard_item,group_quickboard_user,1,1,1,1
access_quickboard_generator,access_quickboard_generator,model_quickboard_generator,group_quickboard_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_ir_model_quick_board access_ir_model_quick_board base.model_ir_model group_quickboard_user 1 0 0 0
3 access_ir_field_quick_board access_ir_field_quick_board base.model_ir_model_fields group_quickboard_user 1 0 0 0
4 access_quickboard_item access_quickboard_item model_quickboard_item group_quickboard_user 1 1 1 1
5 access_quickboard_generator access_quickboard_generator model_quickboard_generator group_quickboard_user 1 1 1 1
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record model="ir.module.category" id="module_category_quickboard">
<field name="name">Quickboard</field>
<field name="description">Quickboard</field>
</record>
<record id="group_quickboard_user" model="res.groups">
<field name="name">Quickboard user</field>
<field name="category_id" ref="module_category_quickboard"/>
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
</record>
<record id="quickboard_item_rule" model="ir.rule">
<field name="name">Quickboard: Items</field>
<field name="model_id" ref="model_quickboard_item"/>
<field name="domain_force">[('create_uid', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
<field name="perm_create" eval="True"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_unlink" eval="True"/>
</record>
</odoo>
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+63
View File
@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="512"
height="512"
viewBox="0 0 135.46666 135.46667"
version="1.1"
id="svg1"
inkscape:version="1.3.2 (091e20e, 2023-11-25, custom)"
sodipodi:docname="icon.svg"
inkscape:export-filename="icon.svg"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#505050"
inkscape:document-units="mm"
inkscape:zoom="1.4378367"
inkscape:cx="97.020753"
inkscape:cy="192.65053"
inkscape:window-width="3200"
inkscape:window-height="1960"
inkscape:window-x="-12"
inkscape:window-y="-12"
inkscape:window-maximized="1"
inkscape:current-layer="g5" />
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<g
id="g5"
transform="matrix(1.1612554,0,0,1.1612119,-10.922364,-10.919417)"
style="stroke-width:0.861153">
<path
id="path1"
style="fill:#bc5fd3;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.911393;stroke-linejoin:round;stroke-miterlimit:3.2"
d="M 67.73333,15.268131 A 52.320512,52.320495 0 0 0 15.412774,67.588688 52.320512,52.320495 0 0 0 67.73333,119.90924 52.320512,52.320495 0 0 0 120.05389,67.588688 52.320512,52.320495 0 0 0 67.73333,15.268131 Z m 0,21.381665 A 30.939746,30.939746 0 0 1 98.672226,67.588688 30.939746,30.939746 0 0 1 67.73333,98.528682 30.939746,30.939746 0 0 1 36.793336,67.588688 30.939746,30.939746 0 0 1 67.73333,36.649796 Z" />
<rect
style="mix-blend-mode:multiply;fill:#71baf4;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.911385;stroke-linejoin:round;stroke-miterlimit:3.2"
id="rect4"
width="74.61174"
height="24.603153"
x="85.679924"
y="-11.51065"
ry="5.8131294"
transform="rotate(45)" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

+32
View File
@@ -0,0 +1,32 @@
/** @odoo-module **/
export const QUICKBOARD_BG_COLORS = {
"def": ["#845ec2","#d65db1","#ff6f91","#ff9671","#ffc75f","#2c73d2","#0081cf","#0089ba","#008e9b","#008f7a"],
"alt": ["#005f73","#ee9b00","#94d2bd","#ca6702","#e9d8a6","#bb3e03","#0a9396","#9b2226","#ae2012","#c0d896"],
"cld": ["#a9d6e5","#89c2d9","#61a5c2","#468faf","#2c7da0","#2a6f97","#014f86","#01497c","#013a63","#012a4a"],
"hot": ["#ffb950","#ffad33","#ff931f","#ff7e33","#fa5e1f","#ec3f13","#b81702","#a50104","#8e0103","#7a0103"],
"ert": ["#bfc882","#7b4618","#a4b75c","#532a09","#647332","#915c27","#3e4c22","#ad8042","#2e401c","#bfab67"],
"clr": ["#1f2ba0","#0063ff","#0087ff","#19b6ec","#038659","#006f26","#563c0c","#803c00","#ed9180","#ff1002"],
"ptl": ["#66c5cc","#f6cf71","#f89c74","#dcb0f2","#87c55f","#9eb9f3","#fe88b1","#c9db74","#8be0a4","#b497e7"],
"pur": ["#f992ad","#fbbcee","#fab4c8","#f78ecf","#cfb9f7","#e0cefd","#a480f2","#d4b0f9","#c580ed","#d199f1"]
}
export const QUICKBOARD_FG_COLORS = {
"def": ["#000000","#ffffff"],
"alt": ["#000000","#ffffff"],
"cld": ["#000000","#ffffff"],
"hot": ["#000000","#ffffff"],
"ert": ["#000000","#ffffff"],
"clr": ["#000000","#ffffff"],
"ptl": ["#000000","#ffffff"],
"pur": ["#000000","#ffffff"]
}
export function getBackgroundColor(index, theme="def") {
let idx = index % QUICKBOARD_BG_COLORS[theme].length;
return QUICKBOARD_BG_COLORS[theme][idx];
}
export function getForegroundColor(index, theme="def") {
let idx = index % QUICKBOARD_FG_COLORS[theme].length;
return QUICKBOARD_FG_COLORS[theme][idx];
}
@@ -0,0 +1,55 @@
/** @odoo-module **/
import { _t } from "@web/core/l10n/translation";
import { Component, useRef, useState, useExternalListener } from "@odoo/owl";
export class QbColorList extends Component {
static template = "quickboard.QbColorList";
static defaultProps = {
forceExpanded: false,
isExpanded: false,
};
static props = {
canToggle: { type: Boolean, optional: true },
colors: Array,
forceExpanded: { type: Boolean, optional: true },
isExpanded: { type: Boolean, optional: true },
onColorSelected: Function,
selectedColor: { type: Number, optional: true },
};
setup() {
this.colorlistRef = useRef("colorlist");
this.state = useState({ isExpanded: this.props.isExpanded });
useExternalListener(window, "click", this.onOutsideClick);
}
get colors() {
return this.props.colors;
}
onColorSelected(id) {
const idx = this.props.colors.indexOf(id);
this.props.onColorSelected(idx);
if (!this.props.forceExpanded) {
this.state.isExpanded = false;
}
}
onOutsideClick(ev) {
if (this.colorlistRef.el.contains(ev.target) || this.props.forceExpanded) {
return;
}
this.state.isExpanded = false;
}
onToggle(ev) {
if (this.props.canToggle) {
ev.preventDefault();
ev.stopPropagation();
this.state.isExpanded = !this.state.isExpanded;
this.colorlistRef.el.firstElementChild.focus();
}
}
}
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="quickboard.QbColorList">
<div class="o_colorlist d-flex flex-wrap align-items-center mw-100 gap-2" aria-atomic="true" t-ref="colorlist">
<t t-if="!props.forceExpanded and !state.isExpanded">
<button t-on-click="onToggle"
role="menuitem"
t-att-data-color="colors[props.selectedColor]"
t-attf-style="background-color: {{ colors[props.selectedColor] }};"
t-attf-class="btn p-0 rounded-0 o_colorlist_toggler"/>
</t>
<t t-else="" t-foreach="props.colors" t-as="colorId" t-key="colorId">
<button t-on-click.prevent.stop="() => this.onColorSelected(colorId)"
role="menuitem"
t-att-data-color="colorId"
t-attf-style="background-color: {{ colorId }};"
t-attf-class="btn p-0 rounded-0 {{ colorId === props.selectedColor ? 'o_colorlist_selected' : '' }}"/>
</t>
</div>
</t>
</templates>
+28
View File
@@ -0,0 +1,28 @@
.quickboard {
background-color: #555;
}
.grid-stack-item-content {
background-color: whitesmoke;
border: 1px solid black;
}
.quickboard-item-basic-title {
font-weight: 500;
}
.quickboard-item-basic-value {
font-size: 3em;
}
.quickboard-item-basic-icon {
font-size: 3em;
}
.quickboard-item-chart-title, .quickboard-item-list-title {
font-weight: 500;
}
.quickboard-item-chart-icon, .quickboard-item-list-icon {
font-size: 1em !important;
}
@@ -0,0 +1,241 @@
/** @odoo-module **/
import { Component, useRef, useEffect, useState, onPatched } from "@odoo/owl";
import { standardActionServiceProps } from "@web/webclient/actions/action_service";
import { registry } from "@web/core/registry";
import { user } from "@web/core/user";
import { useService } from "@web/core/utils/hooks";
import { DateTimeInput } from "@web/core/datetime/datetime_input";
import { SelectMenu } from "@web/core/select_menu/select_menu";
import { deserializeDateTime, serializeDateTime } from "@web/core/l10n/dates";
import { QuickboardItem } from "./quickboard_item";
import { QUICKBOARD_BG_COLORS } from "../core/colors"
class Quickboard extends Component {
static template = "quickboard";
static components = { SelectMenu, DateTimeInput, QuickboardItem };
static props = {
...standardActionServiceProps,
};
setup() {
this.action = useService("action");
this.dialog = useService("dialog");
let theme = "def";
if (user.settings.quickboard_theme) {
theme = user.settings.quickboard_theme;
}
let startDate = luxon.DateTime.local().startOf("month");
if (user.settings.quickboard_start_date) {
startDate = deserializeDateTime(
user.settings.quickboard_start_date
);
}
let endDate = luxon.DateTime.now();
if (user.settings.quickboard_end_date) {
endDate = deserializeDateTime(user.settings.quickboard_end_date);
}
this.gridRef = useRef("grid-stack");
this.state = useState({
"theme": theme,
"startDate": startDate,
"endDate": endDate,
"items": [],
});
this.quickboard = useState(useService("quickboard"));
this.quickboard.getQuickboardItemDefs(
this.state.startDate,
this.state.endDate
);
useEffect(
(isReady) => {
self = this;
let items = Object.entries(this.quickboard.items)
.filter(([k, v]) => !isNaN(k))
.map(([k, v]) => Object.assign({}, v));
this.state.items = items;
},
() => [this.quickboard.isReady]
);
onPatched(() => {
this.gridRef.current =
this.gridRef.current ||
GridStack.init({
float: true,
columnOpts: {
breakpoints: [{ w: 768, c: 1 }],
},
cellHeight: "10rem",
});
if (this.gridRef.current) {
const grid = this.gridRef.current;
grid.batchUpdate();
grid.removeAll(false);
for (let i = 0; i < this.state.items.length; i++) {
const element = document.querySelector(
`#grid-stack-item-${this.state.items[i]["id"]}`
);
if (element) {
grid.makeWidget(element);
}
}
grid.batchUpdate(false);
}
});
this.busService = this.env.services.bus_service;
this.busService.addChannel("quickboard");
this.busService.subscribe("quickboard_updated", ({}) => {
this.onQuickboardUpdated();
});
this.setupNoData();
}
onQuickboardUpdated() {
this.applyFilter();
let grid = this.gridRef.current;
grid.compact();
}
onStartDateChanged(date) {
this.state.startDate = date;
}
onEndDateChanged(date) {
this.state.endDate = date;
}
saveQuickboard(ev) {
let serializedData = this.gridRef.current.save(false);
this.quickboard.saveLayout(serializedData);
}
compact(ev) {
let grid = this.gridRef.current;
grid.compact();
}
async generateQuickboard(ev) {
const cell_width = this.gridRef.current.cellWidth();
// DO NOT REMOVE: Without this the getCellHeight will return weird number
const h_0 = this.gridRef.current.cellHeight().el.clientHeight;
const cell_height = this.gridRef.current.getCellHeight();
const screen_width = screen.width;
const screen_height = screen.height;
this.action.doAction(
{
type: "ir.actions.act_window",
name: "Generate Quickboard",
res_model: "quickboard.generator",
views: [[false, "form"]],
view_mode: "form",
target: "new",
context: {
dialog_size: "medium",
cell_width: cell_width,
cell_height: cell_height,
screen_width: screen_width,
screen_height: screen_height,
},
}
);
}
async addItem(ev) {
this.action.doAction(
{
type: "ir.actions.act_window",
name: "New",
res_model: "quickboard.item",
views: [[false, "form"]],
view_mode: "form",
target: "new",
context: {
dialog_size: "medium",
quick_add: true
},
},
{
onClose: () => {
this.applyFilter();
},
}
);
}
setupNoData() {
Chart.register({
id: "NoData",
afterDraw: function (chart) {
if (
chart.data.datasets
.map((d) => d.data.length)
.reduce((p, a) => p + a, 0) === 0
) {
const ctx = chart.ctx;
const width = chart.width;
const height = chart.height;
chart.clear();
ctx.save();
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("No data to display.", width / 2, height / 2);
ctx.restore();
}
},
});
}
async applyFilter(ev) {
await user.setUserSettings(
"quickboard_start_date",
serializeDateTime(this.state.startDate)
);
await user.setUserSettings(
"quickboard_end_date",
serializeDateTime(this.state.endDate)
);
await this.quickboard.getQuickboardItemDefs();
}
async onSelectTheme(val) {
this.state.theme = val;
await user.setUserSettings("quickboard_theme", val);
this.quickboard.getQuickboardItemDefs();
}
getThemeSelectionItem(label, theme){
return {
value: theme,
label: label,
colors: QUICKBOARD_BG_COLORS[theme]
}
}
get themes() {
return [
this.getThemeSelectionItem("Default", "def"),
this.getThemeSelectionItem("Alternative", "alt"),
this.getThemeSelectionItem("Cold", "cld"),
this.getThemeSelectionItem("Hot", "hot"),
this.getThemeSelectionItem("Earth", "ert"),
this.getThemeSelectionItem("Colorful", "clr"),
this.getThemeSelectionItem("Pastel", "ptl"),
this.getThemeSelectionItem("Pink Purple", "pur"),
];
}
}
registry.category("lazy_components").add("Quickboard", Quickboard);
@@ -0,0 +1,88 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="quickboard" owl="1">
<div class="quickboard h-100 overflow-auto" style="min-height:-webkit-fill-available;">
<div class="container-fluid flex-column flex-md-row bg-light d-flex align-items-center justify-content-between">
<div class="btn-toolbar w-100 w-md-auto my-2 d-flex" role="toolbar">
<div class="btn-group me-1 flex-grow-1" role="group">
<button id="save" type="button" class="btn btn-primary" t-on-click="(ev) => this.saveQuickboard(ev)">
<i class="fa fa-floppy-o me-2 d-none d-md-inline" />Save
</button>
<button id="compant" type="button" class="btn btn-secondary" t-on-click="(ev) => this.compact(ev)">
<i class="fa fa-th me-2 d-none d-md-inline" />Arrange
</button>
</div>
<div class="btn-group ms-1 flex-grow-1" role="group">
<button id="generate" type="button" class="btn btn-secondary" t-on-click="(ev) => this.addItem(ev)">
<i class="fa fa-plus me-2 d-none d-md-inline" />New
</button>
<button id="generate" type="button" class="btn btn-secondary" t-on-click="(ev) => this.generateQuickboard(ev)">
<i class="fa fa-magic me-2 d-none d-md-inline" />Generate
</button>
</div>
</div>
<div class="d-flex my-auto mb-2">
<div class="input-group input-group-sm ms-1 me-1">
<div class="input-group-text d-none d-md-block"><span class="align-middle">Theme</span></div>
<div class="flex-fill" style="width: 5rem;">
<SelectMenu
choices="themes"
value="this.state.theme"
onSelect.bind="this.onSelectTheme"
required="true"
searchable="false"
togglerClass="'fw-normal'"
class="'theme-menu'">
<t t-set-slot="choice" t-slot-scope="choice">
<div class="d-flex">
<t t-foreach="choice.data.colors" t-as="color" t-key="color">
<div class="border border-2 rounded-circle" t-attf-style="background-color: {{ color }}; width: 1.5em; height: 1.5em">
</div>
</t>
</div>
</t>
</SelectMenu>
</div>
</div>
<div class="input-group input-group-sm ms-1 me-1">
<div class="input-group-text d-none d-md-block"><span class="align-middle">Start Date</span></div>
<div class="form-control">
<DateTimeInput type="'date'" value="state.startDate" placeholder="'Start Date'"
onChange="(d) => this.onStartDateChanged(d)"/>
</div>
</div>
<div class="input-group input-group-sm ms-1 me-1">
<div class="input-group-text d-none d-md-block"><span class="align-middle">End Date</span></div>
<div class="form-control">
<DateTimeInput type="'date'" value="state.endDate" placeholder="'End Date'"
onChange="(d) => this.onEndDateChanged(d)"/>
</div>
</div>
<button id="applyFilter" type="button" class="btn btn-primary" t-on-click="(ev) => this.applyFilter(ev)">
Apply
</button>
</div>
</div>
<div class="grid-stack " t-ref="grid-stack">
<div t-foreach="state.items" t-as="item" t-key="item.id"
t-attf-gs-id="{{item.id}}"
t-attf-gs-x="{{item.x_pos}}"
t-attf-gs-y="{{item.y_pos}}"
t-attf-gs-w="{{item.width}}"
t-attf-gs-h="{{item.height}}"
t-attf-id="grid-stack-item-{{item.id}}">
<div class="grid-stack-item-content rounded">
<QuickboardItem itemType="item.type"
itemId="item.id"
action="action"
theme="state.theme"
startDate="state.startDate"
endDate="state.endDate"
/>
</div>
</div>
</div>
</div>
</t>
</templates>
@@ -0,0 +1,36 @@
/** @odoo-module **/
import { Component } from "@odoo/owl";
import { QuickboardItemBasic } from "./quickboard_item_basic";
import { QuickboardItemChart } from "./quickboard_item_chart";
import { QuickboardItemList } from "./quickboard_item_list";
import { standardQuickboardItemProps } from "./standard_quickboard_item_props"
export class QuickboardItem extends Component {
static template = "quickboard.QuickboardItem"
static props = {
...standardQuickboardItemProps,
itemType: { type: String },
}
get _itemComponent(){
if (this.props.itemType === "basic"){
return QuickboardItemBasic;
} else if (this.props.itemType === "chart"){
return QuickboardItemChart
} else if (this.props.itemType === "list"){
return QuickboardItemList
}
return Component;
}
get _itemProps(){
return {
"action": this.props.action,
"itemId": this.props.itemId,
"theme": this.props.theme,
"startDate": this.props.startDate,
"endDate": this.props.endDate
}
}
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="quickboard.QuickboardItem" owl="1">
<t t-component="_itemComponent" t-props="_itemProps"/>
</t>
</templates>
@@ -0,0 +1,73 @@
/** @odoo-module **/
import { Component, useState } from "@odoo/owl";
import { user } from "@web/core/user";
import { useService } from "@web/core/utils/hooks";
import { standardQuickboardItemProps } from "./standard_quickboard_item_props"
export class QuickboardItemBase extends Component {
static props = {
...standardQuickboardItemProps
}
setup() {
this.action = this.props.action;
this.itemId = this.props.itemId;
this.quickboard = useState(useService("quickboard"));
this.busService = this.env.services.bus_service;
this.busService.subscribe("quickboard_item_updated", ({ id }) => {
this.onMessage(id)
});
}
onMessage(id) {
console.log(id);
}
showItemConfig(ev) {
this._showItemConfig();
}
_getSpinnerOpt(){
var opts = {
"lines": 10, // The number of lines to draw
"length": 0, // The length of each line
"width": 2, // The line thickness
"radius": 4, // The radius of the inner circle
"scale": 4, // Scales overall size of the spinner
"corners": 1, // Corner roundness (0..1)
"speed": 0.7, // Rounds per second
"rotate": 0, // The rotation offset
"animation": 'spinner-line-fade-more', // The CSS animation name for the lines
"direction": 1, // 1: clockwise, -1: counterclockwise
"color": '#7a008a', // CSS color or array of colors
"fadeColor": 'transparent', // CSS color or array of colors
"top": '51%', // Top position relative to parent
"left": '50%', // Left position relative to parent
"shadow": '0 0 1px transparent', // Box-shadow for the lines
"zIndex": 2000000000, // The z-index (defaults to 2e9)
"className": 'spinner', // The CSS class to assign to the spinner
"position": 'absolute', // Element positioning
};
return opts;
}
_showItemConfig(){
var self = this;
this.action.doAction({
'type': 'ir.actions.act_window',
'name': 'Quickboard Item',
'res_model': 'quickboard.item',
'res_id': self.itemId,
'views': [[false, 'form']],
'view_mode': 'form',
'target': 'new',
'context': {
'dialog_size': 'medium',
'quick_edit': true
}
});
}
}
@@ -0,0 +1,102 @@
/** @odoo-module **/
import { useState, onMounted, useRef } from "@odoo/owl";
import { parseFloat, parseInteger, parseMonetary } from "@web/views/fields/parsers";
import { formatFloat, formatInteger, formatMonetary } from "@web/views/fields/formatters";;
import { QuickboardItemBase } from "./quickboard_item_base";
import { getBackgroundColor, getForegroundColor } from "../core/colors";
export class QuickboardItemBasic extends QuickboardItemBase {
static template = "quickboard.QuickboardItemBasic";
setup(){
super.setup();
this.gsItemRef = useRef("grid-stack-item");
this.containerRef = useRef("container");
this.spinner = new Spin.Spinner(this._getSpinnerOpt());
this.state = useState({
"title": "",
"icon": "",
"valueFieldType": "",
"aggregateValue": "",
"value": "",
"aggregateFunction": "",
"textColor": "",
"backgroundColor": "",
"theme": this.props.theme,
"startDate": this.props.startDate,
"endDate": this.props.endDate,
});
onMounted(async () => {
var target = this.gsItemRef.el;
this.spinner.spin(target);
await this.loadData(
this.props.itemId,
this.state.startDate,
this.state.endDate
).then(() => {this.spinner.stop()});
})
}
async onMessage(id) {
if (id == this.itemId){
var target = this.gsItemRef.el;
if (this.containerRef.el){
this.containerRef.el.classList.add("d-none");
}
this.spinner.spin(target);
await this.loadData(
this.props.itemId,
this.state.startDate,
this.state.endDate
).then(() => {
if (this.containerRef.el){
this.containerRef.el.classList.remove("d-none");
}
this.spinner.stop()
});
}
}
async loadData(itemId, startDate, endDate) {
const res = await this.quickboard.getQuickboardItem(itemId, startDate, endDate)
this.state.title = res.name;
this.state.icon = res.icon;
this.state.valueFieldType = res.value_field_type;
this.state.aggregateValue = res.aggregate_value;
this.state.value = this.getFormattedValue();
this.state.aggregateFunction = this.aggregate_function;
this.state.textColor = getForegroundColor(res.text_color, this.state.theme);
this.state.backgroundColor = getBackgroundColor(res.background_color, this.state.theme);
}
getFormattedValue(){
let val;
let val_formatted;
switch (this.state.valueFieldType){
case "integer":
val = parseInteger(String(this.state.aggregateValue));
val_formatted = formatInteger(val);
break;
case "float":
val = parseFloat(String(this.state.aggregateValue));
val_formatted = formatFloat(val);
break;
case "monetary":
val = parseMonetary(String(this.state.aggregateValue));
val_formatted = formatMonetary(val);
break;
default:
if (Number.isSafeInteger(this.state.aggregateValue))
val_formatted = formatInteger(this.state.aggregateValue);
else
val_formatted = formatFloat(this.state.aggregateValue);
}
return val_formatted
}
}
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="quickboard.QuickboardItemBasic" owl="1">
<div t-ref="grid-stack-item" class="grid-stack-item h-100 overflow-hidden d-flex flex-column">
<div t-ref="container" class="d-flex flex-column flex-fill p-1">
<div class="d-flex p-1 flex-grow-1"
t-att-style="'color:' + state.textColor + '; background-color: ' + state.backgroundColor">
<div class="quickboard-item-basic-icon ps-2">
<span><i t-att-class="'fa ' + state.icon"/></span>
</div>
<div class="d-flex flex-column text-end flex-grow-1 align-self-end h-100">
<span class="quickboard-item-chart-icon me-1 flex-fill">
<i class="fa fa-cog" t-on-click="(ev) => this.showItemConfig(ev)"/>
</span>
<div class="quickboard-item-basic-value flex-wrap w-100 pe-1">
<span><t t-out="this.state.value"/></span>
</div>
</div>
</div>
<div class="quickboard-item-basic-title text-center"><t t-out="this.state.title"/></div>
</div>
</div>
</t>
</templates>
@@ -0,0 +1,263 @@
/** @odoo-module **/
import { parseDate, parseDateTime } from "@web/core/l10n/dates";
import { useState, useRef, onMounted } from "@odoo/owl";
import { getBackgroundColor } from "../core/colors";
import { QuickboardItemBase } from "./quickboard_item_base";
export class QuickboardItemChart extends QuickboardItemBase {
static template = "quickboard.QuickboardItemChart";
setup() {
super.setup();
this.gsItemRef = useRef("grid-stack-item");
this.spinner = new Spin.Spinner(this._getSpinnerOpt());
this.state = useState({
"title": "",
"icon": "",
"chartType": "",
"data": "",
"valueFieldName": "",
"valueFieldType": "",
"dimensionFieldName": "",
"dimensionFieldType": "",
"datetimeGranularity": "",
"aggregateFunction": "",
"datetimeGranularity": "",
"theme": this.props.theme,
"startDate": this.props.startDate,
"endDate": this.props.endDate,
});
this.chartCanvasRef = useRef("chartCanvas");
onMounted(async () => {
var target = this.gsItemRef.el;
this.spinner.spin(target);
await this.loadData(
this.props.itemId,
this.state.startDate,
this.state.endDate
).then(() => {
this.spinner.stop();
});
});
}
async onMessage(id) {
if (id == this.itemId) {
var target = this.gsItemRef.el;
this.spinner.spin(target);
if (this.chartCanvasRef.el) {
this.chartCanvasRef.el.style.display = "none";
}
await this.loadData(
this.props.itemId,
this.state.startDate,
this.state.endDate
).then(() => {
this.spinner.stop();
});
}
}
async loadData(itemId, startDate, endDate) {
var target = this.gsItemRef.el;
this.spinner.spin(target);
const res = await this.quickboard.getQuickboardItem(
itemId,
startDate,
endDate
);
this.state.title = res.name;
this.state.icon = res.icon;
this.state.chartType = res.chart_type;
this.state.data = res.data;
this.state.valueFieldName = res.value_field_name;
this.state.valueFieldType = res.value_field_type;
this.state.dimensionFieldName = res.dimension_field_name;
this.state.dimensionFieldType = res.dimension_field_type;
this.state.groupFieldName = res.group_field_name;
this.state.groupFieldType = res.group_field_type;
this.state.aggregateFunction = res.aggregate_function;
this.state.datetimeGranularity = res.datetime_granularity;
this.renderChart(
this.state.chartType,
this.state.data,
this.state.valueFieldName,
this.state.aggregateFunction,
this.state.dimensionFieldType,
this.state.datetimeGranularity
);
}
_getCircularChartConfig(chartType, data, valueFieldName, aggregateFunction, dimensionFieldType, datetimeGranularity){
if (chartType === "polar") {
chartType = "polarArea";
}
const chartData = [];
let labels = [];
let dataset_color = [];
data.forEach((element, index) => {
const lbl = Object.entries(element["dataset"])
.filter(([k, v]) => !isNaN(k))
.map(([k, v]) => v[0]);
labels = Array.from(new Set(labels.concat(lbl)))
});
dataset_color = labels.map((_, index) => getBackgroundColor(index, this.state.theme));
data.forEach((element, index) => {
const dt = Object.entries(element["dataset"])
.filter(([k, v]) => !isNaN(k))
.map(([k, v]) => v[1]);
chartData.push({
label: element.label,
data: dt,
backgroundColor: dataset_color
});
});
let chartConfig = {
type: chartType,
data: {
labels: labels,
datasets: chartData,
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: "bottom"
}
}
},
};
return chartConfig;
}
_getXYChartConfig(chartType, data, valueFieldName, aggregateFunction, dimensionFieldType, datetimeGranularity){
const chartData = [];
data.forEach((element, index) => {
const dt = Object.entries(element["dataset"])
.filter(([k, v]) => !isNaN(k))
.map(([k, v]) => {
let x_val;
let y_val;
let dateTimeDimension = ["date", "datetime"].includes(dimensionFieldType)
if (chartType === 'horizontal-bar'){
x_val = v[1];
y_val = dateTimeDimension ? parseDateTime(v[0]) : v[0]
} else {
x_val = dateTimeDimension ? parseDateTime(v[0]) : v[0];
y_val = v[1]
}
return Object.assign({}, {
x: x_val,
y: y_val
})
}
);
const dataset_color = getBackgroundColor(index, this.state.theme);
chartData.push({
label: element.label,
data: dt,
backgroundColor: dataset_color,
borderColor: dataset_color,
borderWidth: 3,
cubicInterpolationMode: 'monotone',
});
});
let x_axis_option = {};
let y_axis_option = {}
if (["date", "datetime"].includes(dimensionFieldType)) {
if (chartType === 'horizontal-bar') {
chartType = 'bar';
y_axis_option = {
indexAxis: 'y',
scales: {
y: {
type: "time",
time: {
unit: datetimeGranularity,
},
},
},
}
} else {
x_axis_option = {
scales: {
x: {
type: "time",
time: {
unit: datetimeGranularity,
},
},
},
};
}
} else {
if (chartType === 'horizontal-bar') {
chartType = 'bar';
y_axis_option = {
indexAxis: 'y',
}
}
}
let chartConfig = {
type: chartType,
data: {
datasets: chartData
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: "bottom"
}
}
},
};
Object.assign(chartConfig.options, x_axis_option);
Object.assign(chartConfig.options, y_axis_option);
return chartConfig;
}
renderChart(chartType, chartData, valueFieldName, aggregateFunction, dimensionFieldType, datetimeGranularity) {
let data = Object.entries(chartData)
.filter(([k, v]) => !isNaN(k))
.map(([k, v]) => Object.assign({}, v));
let chartConfig = {}
if (["doughnut", "pie", "polar"].includes(chartType)) {
chartConfig = this._getCircularChartConfig(chartType, data, valueFieldName, aggregateFunction, dimensionFieldType, datetimeGranularity)
} else {
chartConfig = this._getXYChartConfig(chartType, data, valueFieldName, aggregateFunction, dimensionFieldType, datetimeGranularity)
}
if (this.chartCanvasRef.el) {
const ctx = this.chartCanvasRef.el.getContext("2d");
if (this.chart) {
this.chart.destroy();
}
this.chart = new Chart(ctx, chartConfig);
}
}
}
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="quickboard.QuickboardItemChart" owl="1">
<div t-ref="grid-stack-item" class="grid-stack-item h-100 p-2 pb-5 overflow-hidden">
<div class="quickboard-item-chart-title d-flex justify-content-between">
<div>
<span class="quickboard-item-chart-icon mx-1"><i t-att-class="'fa ' + this.state.icon"/></span>
<span><t t-out="state.title"/></span>
</div>
<span class="quickboard-item-cog me-1"><i class="fa fa-cog" t-on-click="(ev) => this.showItemConfig(ev)"/></span>
</div>
<div class="pt-4 px-3 w-100 h-100">
<div class="h-100 w-100">
<canvas t-ref="chartCanvas" />
</div>
</div>
</div>
</t>
</templates>
@@ -0,0 +1,112 @@
/** @odoo-module **/
import { useState, useRef, onMounted } from "@odoo/owl";
import { parseFloat, parseInteger, parseMonetary } from "@web/views/fields/parsers";
import { formatFloat, formatInteger, formatMonetary } from "@web/views/fields/formatters";;
import { QuickboardItemBase } from "./quickboard_item_base";
export class QuickboardItemList extends QuickboardItemBase {
static template = "quickboard.QuickboardItemList";
setup() {
super.setup();
this.gsItemRef = useRef("grid-stack-item");
this.containerRef = useRef("container");
this.spinner = new Spin.Spinner(this._getSpinnerOpt());
this.state = useState({
"title": "",
"icon": "",
"data": "",
"valueFieldName": "",
"valueFieldType": "",
"aggregateFunction": "",
"dimensionFieldName": "",
"dimensionFieldType": "",
"startDate": this.props.startDate,
"endDate": this.props.endDate,
});
onMounted(async () => {
var target = this.gsItemRef.el;
this.spinner.spin(target);
await this.loadData(
this.props.itemId,
this.state.startDate,
this.state.endDate
).then(() => {
this.spinner.stop();
});
});
}
async onMessage(id) {
if (id == this.itemId) {
var target = this.gsItemRef.el;
if (this.containerRef.el){
this.containerRef.el.classList.add("d-none");
}
this.spinner.spin(target);
await this.loadData(
this.props.itemId,
this.state.startDate,
this.state.endDate
).then(() => {
if (this.containerRef.el){
this.containerRef.el.classList.remove("d-none");
}
this.spinner.stop();
});
}
}
async loadData(itemId, startDate, endDate) {
var target = this.gsItemRef.el;
this.spinner.spin(target);
const res = await this.quickboard.getQuickboardItem(
itemId,
startDate,
endDate
);
this.state.title = res.name;
this.state.icon = res.icon;
this.state.data = res.data;
this.state.valueFieldName = res.value_field_name;
this.state.valueFieldType = res.value_field_type;
this.state.dimensionFieldName = res.dimension_field_name;
this.state.dimensionFieldType = res.dimension_field_type;
this.state.aggregateFunction = res.aggregate_function;
}
formatValue(value){
let val;
let val_formatted;
switch (this.state.valueFieldType){
case "integer":
val = parseInteger(String(value));
val_formatted = formatInteger(val);
break;
case "float":
val = parseFloat(String(value));
val_formatted = formatFloat(val);
break;
case "monetary":
val = parseMonetary(String(value));
val_formatted = formatMonetary(val);
break;
default:
if (Number.isSafeInteger(value))
val_formatted = formatInteger(value);
else
val_formatted = formatFloat(value);
}
return val_formatted
}
}
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="quickboard.QuickboardItemList" owl="1">
<div t-ref="grid-stack-item" class="grid-stack-item h-100 p-2 pb-5 overflow-hidden">
<div class="quickboard-item-list-title d-flex justify-content-between">
<div>
<span class="quickboard-item-list-icon mx-1"><i t-att-class="'fa ' + this.state.icon"/></span>
<span><t t-out="state.title"/></span>
</div>
<span class="quickboard-item-cog me-1"><i class="fa fa-cog" t-on-click="(ev) => this.showItemConfig(ev)"/></span>
</div>
<div t-ref="container" class="d-flex flex-column flex-fill p-1">
<div class="p-3 w-100 h-100">
<div class="h-100 w-100 d-flex">
<t t-if="state.data.length > 0" >
<table class="table table-sm table-hover table-striped align-self-start">
<thead>
<tr>
<th scope="col"><t t-out="state.dimensionFieldName"/></th>
<th scope="col" class="text-end"><t t-out="state.valueFieldName"/></th>
</tr>
</thead>
<tbody>
<tr t-foreach="state.data" t-as="item" t-key="item.seq">
<td><t t-out="item['x']"/></td>
<td class="text-end"><t t-esc="formatValue(item['y'])"/></td>
</tr>
</tbody>
</table>
</t>
<t t-else="">
<div class="flex-fill text-center align-self-center">
No data to display.
</div>
</t>
</div>
</div>
</div>
</div>
</t>
</templates>
@@ -0,0 +1,58 @@
/** @odoo-module */
import { registry } from "@web/core/registry";
import { reactive } from "@odoo/owl";
import { rpc } from "@web/core/network/rpc";
const quickboardService = {
start(env, services) {
const quickboard = reactive({
items: {},
isReady: false
});
async function getQuickboardItemDefs() {
quickboard.isReady = false;
quickboard.items = {};
const updates = await rpc("/quickboard/item_defs",{});
Object.assign(quickboard.items, updates);
quickboard.isReady = true;
};
async function getQuickboardItem(itemId, startDate, endDate) {
return await rpc("/quickboard/item",{
item_id: itemId,
start_date: startDate.toSQLDate(),
end_date: endDate.toSQLDate()
});
};
async function saveLayout(layout){
await rpc("/quickboard/save_layout",{
"layout": layout
});
};
async function saveFilter(startDate, endDate) {
return await rpc("/quickboard/save_filter",{
start_date: startDate.toSQLDate(),
end_date: endDate.toSQLDate()
});
};
async function saveTheme(theme) {
return await rpc("/quickboard/save_theme",{
theme: theme
});
};
quickboard.saveTheme = saveTheme;
quickboard.saveFilter = saveFilter;
quickboard.getQuickboardItemDefs = getQuickboardItemDefs;
quickboard.getQuickboardItem = getQuickboardItem;
quickboard.saveLayout = saveLayout;
return quickboard;
}
};
registry.category("services").add("quickboard", quickboardService);
@@ -0,0 +1,10 @@
/** @odoo-module **/
export const standardQuickboardItemProps = {
action: { type: Object },
itemId: { type: Number },
theme: { type: String },
startDate: { type: luxon.DateTime },
endDate: { type: luxon.DateTime },
};
@@ -0,0 +1,20 @@
/** @odoo-module */
import { registry } from "@web/core/registry";
import { LazyComponent } from "@web/core/assets";
import { Component, xml } from "@odoo/owl";
import { standardActionServiceProps } from "@web/webclient/actions/action_service";
class QuickboardLoader extends Component {
static components = { LazyComponent };
static template = xml`
<LazyComponent bundle="'quickboard.assets'" Component="'Quickboard'" props="props"/>
`;
static props = {
...standardActionServiceProps,
props: { type: Object, optional: true },
Component: { type: Function, optional: true },
};
}
registry.category("actions").add("quickboard", QuickboardLoader);
@@ -0,0 +1,63 @@
/** @odoo-module **/
import { _t } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { standardFieldProps } from "@web/views/fields/standard_field_props";
import { QbColorList } from "../../../core/qb_color_list/qb_color_list";
import { user } from "@web/core/user";
import { Component } from "@odoo/owl";
import { QUICKBOARD_BG_COLORS, QUICKBOARD_FG_COLORS } from "../../../core/colors";
export class QbColorPickerField extends Component {
static template = "quickboard.QbColorPickerField";
static components = {
QbColorList,
};
static props = {
...standardFieldProps,
canToggle: { type: Boolean },
mode: { type: String },
};
currentColorPalette() {
let theme = user.settings.quickboard_theme;
if (this.props.mode === "foreground"){
return QUICKBOARD_FG_COLORS[theme];
} else {
return QUICKBOARD_BG_COLORS[theme];
}
}
get isExpanded() {
return !this.props.canToggle && !this.props.readonly;
}
switchColor(colorIndex) {
this.props.record.update({ [this.props.name]: colorIndex });
}
}
export const qbColorPickerField = {
component: QbColorPickerField,
supportedTypes: ["integer"],
supportedOptions: [
{
label: _t("Mode"),
name: "mode",
type: "selection",
choices: [
{ label: "Foreground", value: "fg" },
{ label: "Background", value: "bg" },
],
default: "bg",
},
],
extractProps: ({ options, viewType }) => ({
canToggle: viewType !== "list",
mode: options.mode,
}),
};
registry.category("fields").add("qb_color_picker", qbColorPickerField);
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="quickboard.QbColorPickerField">
<QbColorList canToggle="props.canToggle"
colors="currentColorPalette()"
forceExpanded="isExpanded"
onColorSelected.bind="switchColor"
selectedColor="props.record.data[props.name] || 0"/>
</t>
</templates>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,35 @@
/** @odoo-module **/
import { _t } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { standardFieldProps } from "@web/views/fields/standard_field_props";
import { SelectMenu } from "@web/core/select_menu/select_menu";
import { Component } from "@odoo/owl";
import { fa_icons } from "./fa_icons";
export class QbIconPickerField extends Component {
static template = "quickboard.QbIconPickerField";
static components = {
SelectMenu,
};
static props = {
...standardFieldProps,
};
async onSelectIcon(val) {
this.props.record.update({ [this.props.name]: val });
}
get icons() {
return fa_icons;
}
}
export const qbIconPickerField = {
component: QbIconPickerField,
supportedTypes: ["char"],
};
registry.category("fields").add("qb_icon_picker", qbIconPickerField);
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="quickboard.QbIconPickerField">
<SelectMenu
choices="icons"
value="props.record.data[props.name] || ''"
onSelect.bind="this.onSelectIcon"
required="false"
searchable="true"
togglerClass="'fw-normal'"
class="'icon-select'">
<t t-set-slot="choice" t-slot-scope="choice">
<i t-attf-class="{{ 'fa ' + choice.data.value + ' me-1' }}"/><t t-esc="choice.data.label" />
</t>
</SelectMenu>
</t>
</templates>
@@ -0,0 +1,93 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<!-- List view, also called list view on models -->
<record id="quickboard_item_view_list" model="ir.ui.view">
<field name="name">quickboard.item.list</field>
<field name="model">quickboard.item</field>
<field name="arch" type="xml">
<list string="Quickboard Item">
<field name="name"/>
<field name="model_name"/>
<field name="chart_type"/>
<field name="type"/>
</list>
</field>
</record>
<!-- Form view on models -->
<record id="quickboard_item_view_form" model="ir.ui.view">
<field name="name">quickboard.item.form</field>
<field name="model">quickboard.item</field>
<field name="arch" type="xml">
<form string="Quickboard Item">
<sheet>
<group>
<group string="General" >
<field name="type"
required="1"
readonly="context.get('quick_edit', False)"/>
<field name="name" required="1"/>
<field name="model_id"
required="1"
options="{'no_create_edit':True,'no_create': True}"
domain="[('transient', '=', False)]"/>
<field name="model_name" invisible="1" />
</group>
<group string="Options">
<field name="icon" widget="qb_icon_picker"/>
<field name="text_color" widget="qb_color_picker" options="{'mode': 'foreground'}" invisible="type != 'basic'" />
<field name="background_color" widget="qb_color_picker" options="{'mode': 'background'}" invisible="type != 'basic'" />
<field name="chart_type" required="[('type', '=', 'chart')]" invisible="type != 'chart'"/>
</group>
</group>
<group string="Data">
<group>
<field name="value_field_id"
required="1"
options="{'no_create_edit':True,'no_create': True}"
domain="[('model_id','=',model_id), ('store', '=', True), ('ttype', 'not in', ['one2many', 'many2many'])]"
widget="many2many_tags" />
<field name="aggregate_function" required="1" />
</group>
<group>
<field name="dimension_field_id"
required="type != 'basic'"
options="{'no_create_edit':True,'no_create': True}"
domain="[('model_id','=',model_id), ('store', '=', True)]"
invisible="type == 'basic'"/>
<field name="datetime_granularity"
required="[('dimension_field_id.ttype', 'in', ['date', 'datetime'])]"
invisible="type != 'chart'"/>
<field name="group_field_id"
options="{'no_create_edit':True,'no_create': True}"
domain="[('model_id','=',model_id), ('store', '=', True)]"
invisible="type != 'chart' or value_field_id.length > 1"/>
<field name="list_row_limit"
required="type == 'list'"
invisible="type != 'list'"/>
</group>
</group>
<group string="Filter">
<field name="domain_filter" widget="domain" options="{'model': 'model_name'}"/>
</group>
<div class="alert" >
<span> Visit <a href="https://fontawesome.com/v4/icons/" target="new">here</a> for icon values.</span>
</div>
</sheet>
</form>
</field>
</record>
<record id="quickboard_item_action_window" model="ir.actions.act_window">
<field name="name">Quickboard Item</field>
<field name="res_model">quickboard.item</field>
<field name="view_mode">list,form</field>
</record>
<!-- Sub Menu item -->
<menuitem name="Items" id="quickboard_item_menu_action_window" parent="quickboard.menu_root"
groups="group_quickboard_user" action="quickboard_item_action_window"/>
</data>
</odoo>
+14
View File
@@ -0,0 +1,14 @@
<odoo>
<data>
<record model="ir.actions.client" id="quickboard">
<field name="name">Quickboard</field>
<field name="tag">quickboard</field>
</record>
<menuitem name="Quickboard" id="quickboard.menu_root"
groups="group_quickboard_user" web_icon="quickboard,static/description/icon.png"/>
<menuitem name="Quickboard" id="quickboard.quickboard_menu"
groups="group_quickboard_user" parent="quickboard.menu_root" action="quickboard" sequence="1"/>
</data>
</odoo>
+2
View File
@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import quickboard_generator
+7
View File
@@ -0,0 +1,7 @@
from .json_validator_agent import UserProxyAgentForJsonValidation
from .quickboard_ai_generator import QuickboardAiGenerator
__all__ = [
"UserProxyAgentForJsonValidation",
"QuickboardAiGenerator"
]
+239
View File
@@ -0,0 +1,239 @@
# -*- coding: utf-8 -*-
from autogen import AssistantAgent, UserProxyAgent, filter_config
from textwrap import dedent
from prettytable import PrettyTable
# API_KEY = "_ollama_"
# BASE_URL = "http://localhost:11434/v1"
API_KEY = "_lmstudio_"
BASE_URL = "http://localhost:1234/v1"
LLM_MODEL = "qwen2.5-coder-7b-instruct"
DEFAULT_AUTOGEN_CONFIG_LIST = [
{
"model": LLM_MODEL,
"base_url": BASE_URL,
"api_key": API_KEY,
},
]
DEFAULT_AUTOGEN_LLM_CONFIG = {
"config_list": DEFAULT_AUTOGEN_CONFIG_LIST,
"cache_seed": None,
"temperature": 0.3,
"seed": 10
}
QUICKBOARD_BG_COLORS = ["#845ec2","#d65db1","#ff6f91","#ff9671","#ffc75f","#2c73d2","#0081cf","#0089ba","#008e9b","#008f7a"]
QUICKBOARD_FG_COLORS = ["#000000", "$ffffff"]
QUICKBOARD_DATA_UI_JSON_SCHEMA = {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Generated schema for Root",
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"icon": {
"type": "string"
},
"type": {
"enum": ["basic", "chart", "list"]
},
"model": {
"type": "string"
},
"value_field": {
"type": "string"
},
"aggregate_function": {
"enum": ["avg", "count", "max", "min", "sum"]
},
"text_color": {
"type": "string"
},
"background_color": {
"type": "string"
},
"x_pos": {
"type": "integer"
},
"y_pos": {
"type": "integer"
},
"width": {
"type": "integer"
},
"height": {
"type": "integer"
},
"dimension_field": {
"type": "string"
},
"chart_type": {
"enum": ["bar", 'horizontal-bar' "doughnut", "line", "pie", "polar"]
},
"list_row_limit": {
"type": "integer"
}
},
"allOf": [
{
"if": {
"properties": {
"type": { "const": "chart" }
}
},
"then": {
"required": ["chart_type", "dimension_field"]
},
"if": {
"properties": {
"type": { "const": "list" }
}
},
"then": {
"required": ["list_row_limit"]
}
},
],
"required": [
"name",
"icon",
"type",
"model",
"value_field",
"aggregate_function",
"x_pos",
"y_pos",
"width",
"height"
]
}
}
QUICKBOARD_DATA_ONLY_JSON_SCHEMA = {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Generated schema for Root",
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"icon": {
"type": "string"
},
"type": {
"enum": ["basic", "chart", "list"]
},
"model": {
"type": "string"
},
"value_field": {
"type": "string"
},
"aggregate_function": {
"enum": ["avg", "count", "max", "min", "sum"]
},
"text_color": {
"type": "string"
},
"background_color": {
"type": "string"
},
"dimension_field": {
"type": "string"
},
"chart_type": {
"enum": ["bar", "horizontal-bar", "doughnut", "line", "pie", "polar"]
},
"list_row_limit": {
"type": "integer"
}
},
"allOf": [
{
"if": {
"properties": {
"type": { "const": "chart" }
}
},
"then": {
"required": ["chart_type", "dimension_field"]
}
},
{
"if": {
"properties": {
"type": { "const": "basic" }
}
},
"then": {
"required": ["text_color", "background_color"]
}
},
{
"if": {
"properties": {
"type": { "const": "list" }
}
},
"then": {
"required": ["list_row_limit"]
}
}
],
"required": [
"name",
"icon",
"type",
"model",
"value_field",
"aggregate_function"
]
}
}
QUICKBOARD_LAYOUT_JSON_SCHEMA = {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Generated schema for Root",
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "integer"
},
"type": {
"enum": ["basic", "chart", "list"]
},
"x_pos": {
"type": "integer"
},
"y_pos": {
"type": "integer"
},
"width": {
"type": "integer"
},
"height": {
"type": "integer"
}
},
"required": [
"id",
"type",
"x_pos",
"y_pos",
"width",
"height"
]
}
}
@@ -0,0 +1,115 @@
# -*- coding: utf-8 -*-
import json
from jsonschema import validate
from textwrap import dedent
from typing import Any, Callable, Dict, List, Optional, Literal, Optional, Union
from autogen import Agent, ConversableAgent
from autogen.coding import CodeExecutor, CodeExtractor, MarkdownCodeExtractor, CodeBlock, CodeResult
from autogen.runtime_logging import log_new_agent, logging_enabled
class JsonValidator(CodeExecutor):
def __init__(self, json_schema, **kwargs):
self.json_schema = json_schema
@property
def code_extractor(self) -> CodeExtractor:
return MarkdownCodeExtractor()
def execute_code_blocks(self, code_blocks: List[CodeBlock]) -> CodeResult:
logs_all = ""
exitcode = 0
json_code_block_count = 0
for idx, code_block in enumerate(code_blocks, start=1):
lang, code = code_block.language, code_block.code
lang = lang.lower()
if lang != "json":
logs_all += "\n" + f"Skipping execution: language not supported (code block #{idx})."
continue
try:
quickboard_json = json.loads(code)
validate(quickboard_json, self.json_schema)
except Exception as e:
exitcode = -1
logs_all += f"\nError: {str(e)}"
break
exitcode = 0
logs_all += f"Json is valid."
json_code_block_count += 1
if (exitcode == 0 and json_code_block_count > 0) or exitcode == -1:
return CodeResult(exit_code=exitcode, output=logs_all)
return CodeResult(exit_code=-1, output="Invalid or no json code block was detected, please make sure your code block is marked as json.")
def restart(self) -> None:
self.engine.dispose()
class UserProxyAgentForJsonValidation(ConversableAgent):
DEFAULT_USER_PROXY_AGENT_DESCRIPTIONS = {
"ALWAYS": dedent(\
"""An attentive HUMAN user who can answer questions about the task, and can perform tasks such as validating json
using software tools and reporting back the execution results."""),
"TERMINATE": dedent(\
"""A user that can validate json using software tools and report back the execution results."""),
"NEVER": dedent(\
"""An bot that performs no other action than validating json (provided to it's quoted in json blocks)."""),
}
def __init__(
self,
name: str,
json_schema: object,
is_termination_msg: Optional[Callable[[Dict], bool]] = None,
max_consecutive_auto_reply: Optional[int] = None,
human_input_mode: Literal["ALWAYS", "NEVER", "TERMINATE"] = "NEVER",
default_auto_reply: Union[str, Dict] = "",
description: Optional[str] = None,
):
json_validator = JsonValidator(json_schema=json_schema)
super().__init__(
name=name,
is_termination_msg=is_termination_msg,
max_consecutive_auto_reply=max_consecutive_auto_reply,
human_input_mode=human_input_mode,
code_execution_config={"executor": json_validator},
default_auto_reply=default_auto_reply,
description=(
description if description is not None else self.DEFAULT_USER_PROXY_AGENT_DESCRIPTIONS[human_input_mode]
),
)
if logging_enabled():
log_new_agent(self, locals())
def run_code(self, code, **kwargs):
return -1, "Not supported", None
def execute_code_blocks(self, code_blocks):
return -1, "Not supported"
def generate_reply(
self,
messages: Optional[List[Dict[str, Any]]] = None,
sender: Optional["Agent"] = None,
**kwargs: Any,
) -> Union[str, Dict, None]:
res = super().generate_reply(messages, sender)
res_ok = True
if isinstance(res, Dict) and len(dict) == 0:
res_ok = False
elif isinstance(res, str) and str == "":
res_ok = False
elif res is None:
res_ok = False
if not res_ok:
msg = "Invalid or no json code block was detected, please make sure your code block is marked as json."
return f"exitcode: -1 (execution failed)\nCode output: {msg}"
return res
@@ -0,0 +1,628 @@
# -*- coding: utf-8 -*-
import json
import copy
from textwrap import dedent
from prettytable import PrettyTable
from autogen import Agent, AssistantAgent, UserProxyAgent, GroupChat, GroupChatManager
from autogen.coding import MarkdownCodeExtractor
from .consts import DEFAULT_AUTOGEN_LLM_CONFIG, QUICKBOARD_FG_COLORS, QUICKBOARD_BG_COLORS, QUICKBOARD_DATA_ONLY_JSON_SCHEMA, QUICKBOARD_LAYOUT_JSON_SCHEMA
from .json_validator_agent import UserProxyAgentForJsonValidation
class QuickboardAiGenerator:
_QUICKBOARD_GENERATOR_SYSTEM_MESSAGE = f"""
You are an AI assistant specializing in data analysis.
Your task is to assist user to build an intuitive and informative dashboard.
You help by creating a list dashboard items base on the fields from the given tables.
Ignore all your previous knowledge about models, here models are the same with relational database tables.
Only use the models as mentioned here with common sense, e.g. when the model name is 'sale.order'
then the model is about sales orders, etc.
## DASHBOARD ITEM TYPES
There are two kind of dashboard items, 'basic', 'list' and 'chart'.
A 'basic' item is used for single value KPI, 'list' items are usualy used to show top (n) data in a tabular list
while a 'chart' item is used to show data in a chart.
Both shared the following common parameters:
1. name (string): The title of the item. Required.
2. icon (string): Font awesome version 4.7 icon in 'fa-*' format. Required.
3. type (string): Dashboard item type. Required.
4. model (string): model name, e.g. "sale.order", "product.product", etc. Required.
5. value_field (string): Field of the chosen model for the value to be shown, only accept one field. Required.
6. aggregate_function (string): Function to be applied on the value field. Required.
Basic item has the following parameters in addition of the common parameters above:
1. text_color (string): Text color for the item. Required.
2. background_color (string): Background color for the item. Required.
Chart item has the following parameters in addition of the common parameters above:
1. dimension_field (string): Field of the chosen model for the grouping of the data, only accept one field. Required.
2. chart_type (string): The chart type. Required.
List item has the following parameters in addition of the common parameters above:
1. dimension_field (string): Field of the chosen model for the grouping of the data, only accept one field. Required.
2. list_row_limit (integer): Number of rows in the list. Required.
## COLORS
Use these color palette for text color parameters: {QUICKBOARD_FG_COLORS}
Use these color palette for background color parameters: {QUICKBOARD_BG_COLORS}
#EXAMPLE
Given a model with name 'sale.order' which have the following fields:
+-------------+------------------------+-----------+-------------------------+-------------------+
| Model | Name | Type | Description | Usage |
+-------------+------------------------+-----------+-------------------------+-------------------+
| ... | ... | ... | ... | ... |
| sale.order | id | integer | ID | value, dimension |
| sale.order | date_order | datetime | Order Date | dimension |
| sale.order | medium_id | integer | Medium | dimension |
| sale.order | sale_order_option_ids | list | Optional Products Lines | |
| sale.order | amount_total | monetary | Total | value |
| sale.order | amount_to_invoice | monetary | Amount to invoice | value |
| ... | ... | ... | ... | ... |
+-------------+------------------------+-----------+-------------------------+-------------------+
And a model with name 'sale.order.line' which have the following fields:
+------------------+---------------------------+-----------+--------------------------------+-------------------+
| Model | Name | Type | Description | Usage |
+------------------+---------------------------+-----------+--------------------------------+-------------------+
| ... | ... | ... | ... | ... |
| sale.order.line | product_id | integer | Product | value, dimension |
| sale.order.line | product_uom_qty | float | Quantity | value |
| sale.order.line | qty_delivered_method | char | Method to update delivered qty | value, dimension |
| sale.order.line | qty_delivered | float | Delivery Quantity | value |
| ... | ... | ... | ... | ... |
+------------------+---------------------------+-----------+--------------------------------+-------------------+
You may choose to answer as follow:
```json
[
{{
\"name\": \"Sales Order Count\",
\"icon\": \"fa-shopping-bag\",
\"type\": \"basic\",
\"model\": \"sale.order\",
\"value_field\": \"id\",
\"aggregate_function\": \"count\",
\"text_color\": \"#000000\",
\"background_color\": \"#FFFFFF\"
}},
{{
\"name\": \"Sales Order Total\",
\"icon\": \"fa-shopping-cart\",
\"type\": \"basic\",
\"model\": \"sale.order\",
\"value_field\": \"amount_total\",
\"aggregate_function\": \"sum\",
\"text_color\": \"#000000\",
\"background_color\": \"#3b3b3b\"
}},
{{
\"name\": \"Total Amount By Date\",
\"icon\": \"fa-usd\",
\"type\": \"chart\",
\"model\": \"sale.order\",
\"value_field\": \"amount_total\",
\"aggregate_function\": \"sum\",
\"dimension_field\": \"date_order\",
\"datetime_granularity\": \"day\",
\"chart_type\": \"line\"
}},
{{
\"name\": \"Top 10 Products\",
\"icon\": \"fa-shopping-bag\",
\"type\": \"list\",
\"model\": \"sale.order.line\",
\"value_field\": \"product_uom_qty\",
\"aggregate_function\": \"sum\",
\"dimension_field\": \"product_id\",
\"list_row_limit\": 10
}}
]
```
## RULES
1. Answer only with the definitions of the items in a json list fenced in json code block.
2. Do not comment. Do not explain your answer.
3. Do not use models other than the user specifies.
4. Pay attention to which field belong to which model. Do not to use fields from other models.
5. Treat each model independently, do not mix fields from one model to another.
6. All parameters are required, never set any parameter to null.
7. Set parameter 'type' to 'basic' for basic items, set it to 'list' for list items and set it to 'chart' for chart items.
8. Parameter 'chart_type' must be one of ['bar', 'horizontal-bar', 'doughnut', 'line', 'pie', 'polar'].
9. Use one of ['avg', 'count', 'max', 'min', 'sum'] for 'aggregate_function' if the 'value_field' is one of ['integer', 'float', 'monetary'].
10. For other types of 'value_field' such as 'char', 'many2one', etc., the parameter 'aggregate_function' must only be 'count'.
11. Never use field with type 'date' or 'datetime' for 'value_field'.
12. Parameter 'value_field' and 'dimension_field', requires exact field name, use the field as is without prefixes nor suffixes.
13. If the type of 'dimension_field' is date or datetime, you may add 'datetime_granularity' parameter to specify the precision.
'datetime_granularity' must be one of ["year", "month", "day"].
14. Do not assume a field has relation to other model, so again, 'value_field' and 'dimension_field' must use the exact name as mentioned here.
15. Use only single color for color related parameters, not an array of colors, choose one of the colors mentioned above.
16. Fence your anwser with markdown json code block (```json your_answer ```).
17. Your answer will be validated by a bot, if your answer is not valid then you must fix it.
18. When fixing answer re-evaluate everything and do not give comment on the json code block as it will create another error.
19. When fixing answer always reply with the the fixed json code block with every items.
"""
def __init__(self, env):
self.env = env
self._admin: UserProxyAgent = None
self._quickboard_ai: AssistantAgent = None
self._json_validator: UserProxyAgentForJsonValidation = None
self._groupchat: GroupChat = None
self._manager: GroupChatManager = None
def _create_agents(self, data_json_schema):
admin = UserProxyAgent(
"admin",
description="The user who give tasks and questions.",
human_input_mode="NEVER",
is_termination_msg=lambda message: True, # Always True
code_execution_config=False,
)
quickboard_generator = AssistantAgent(
name="quickboard_generator",
description=f"AI that generate dashboards.",
system_message=dedent(self._QUICKBOARD_GENERATOR_SYSTEM_MESSAGE),
human_input_mode="NEVER",
llm_config=DEFAULT_AUTOGEN_LLM_CONFIG,
)
data_json_validator = UserProxyAgentForJsonValidation(
"data_json_validator",
json_schema=data_json_schema,
description="An bot that performs no other action than validating json (provided to it's quoted in json blocks).",
human_input_mode="NEVER",
)
def _speaker_selection_func(last_speaker: Agent, groupchat: GroupChat):
last_messages = groupchat.messages[-1]
next_speaker = admin
if last_speaker is admin:
next_speaker = quickboard_generator
elif last_speaker is quickboard_generator:
next_speaker = data_json_validator
elif last_speaker is data_json_validator:
# if last agent reply with invalid json then let it try again
if last_messages["content"].find("exitcode: -1") > -1:
next_speaker = quickboard_generator
elif last_messages["content"].strip() == "":
next_speaker = quickboard_generator
return next_speaker
groupchat = GroupChat(
agents=[admin, quickboard_generator, data_json_validator],
messages=[],
max_round=5,
speaker_selection_method= _speaker_selection_func,
# send_introductions=True,
)
manager = GroupChatManager(
groupchat=groupchat,
name="chat_manager",
llm_config=DEFAULT_AUTOGEN_LLM_CONFIG
)
return (admin, quickboard_generator, data_json_validator, manager, groupchat)
def _build_agent_parameters(self, models):
model_names = []
model_infos = []
data_json_schema = copy.deepcopy(QUICKBOARD_DATA_ONLY_JSON_SCHEMA)
for model in models:
field_defs = PrettyTable()
field_defs.align = "l"
field_defs.field_names = ["Model", "Name", "Type", "Description", "Usage"]
fields = self.env[model.model].fields_get()
valid_value_fields = []
valid_dimension_fields = []
for k, v in fields.items():
if v["store"]:
if v["type"] in ["many2many", "one2many"]:
field_type = "list"
elif v["type"] == "selection":
field_type = "char"
elif v["type"] == "many2one":
field_type = "integer"
else:
field_type = v["type"]
usage = []
if v["type"] not in ["many2many", "one2many", "float", "monetary"]:
valid_dimension_fields.append(f"{k}")
usage.append("dimension")
if v["type"] in ["integer", "float", "monetary", "many2one", "selection"]:
valid_value_fields.append(f"{k}")
usage.append("value")
field_defs.add_row([model.model, f"{k}", field_type, v['string'], ",".join(usage)])
valid_value_fields_str = ", ".join([f"'{o}'" for o in valid_value_fields])
valid_dimension_fields_str = ", ".join([f"'{o}'" for o in valid_dimension_fields])
model_names.append(model.model)
model_infos.append({
"name": model.model,
"field_defs": field_defs.get_string(),
"value_fields": valid_value_fields_str,
"dimension_fields": valid_dimension_fields_str
})
model_name_schema = {
"const": model.model
}
value_field_schema = {
"if": {
"properties": {
"model": model_name_schema
}
},
"then": {
"properties": {
"value_field": {
"enum": valid_value_fields
}
}
}
}
dimension_field_schema = {
"if": {
"properties": {
"model": model_name_schema
}
},
"then": {
"properties": {
"dimension_field": {
"enum": valid_dimension_fields
}
}
}
}
data_json_schema["items"]["model"] = model_name_schema
data_json_schema["items"]["allOf"].append(value_field_schema)
data_json_schema["items"]["allOf"].append(dimension_field_schema)
return model_names, model_infos, data_json_schema
def _create_message(self, model_names, model_infos):
message = dedent(f"""
Create dashboard items from these models {[o for o in model_names]}.
""")
for mi in model_infos:
mi_string = dedent(f"""
### MODEL '{mi["name"]}'
The model '{mi["name"]}' has the following fields:
{mi["field_defs"]}
""")
message = message + "\n" + mi_string
message += dedent("""
Pay attention to the field usage. Valid values for 'value_field' and 'dimension_field' are based on it.
Also pay attention to which model a field belongs to, do not use other models field in a dashboard item.
Always use the exact field name as mentioned above on the models field.
Create at least 3 'basic' items, 3 'list' items and 3 'chart' items.
Make the 'basic' items background colorful with matching but still readable text color.
Answer only in a json list fenced in a json code block.
""")
return message
def _arrange_items(self, quickboard_items):
items = copy.deepcopy(quickboard_items)
basic_items = [o for o in items if o["type"] == "basic"]
list_chart_items = [o for o in items if o["type"] != "basic"]
fin = []
row = 0
while len(basic_items) > 0:
count = 1
width = 6
if len(basic_items) >= 6:
count = 6
width = 2
elif len(basic_items) >= 4:
count = 4
width = 3
elif len(basic_items) >= 3:
count = 3
width = 4
elif len(basic_items) >= 2:
count = 2
width = 6
row_items = basic_items[:count]
for k, o in enumerate(row_items):
o["y_pos"] = row
o["x_pos"] = k * width
o["height"] = 1
o["width"] = width
fin.extend(row_items)
del basic_items[:count]
row += 1
while len(list_chart_items) > 0:
width = 4
count = 3
if len(list_chart_items) >= 4:
count = 4
width = 3
elif len(list_chart_items) >= 3:
count = 3
width = 4
elif len(list_chart_items) >= 2:
count = 2
width = 6
row_items = list_chart_items[:count]
for k, o in enumerate(row_items):
o["y_pos"] = row
o["x_pos"] = k * width
o["height"] = 2
o["width"] = width
fin.extend(row_items)
del list_chart_items[:count]
row += 2
return fin
def generate_quickboard(self, models, layout_by_ai, screen_w, screen_h, cell_w, cell_h):
model_names, model_infos, json_schema = self._build_agent_parameters(models)
self._admin,\
self._quickboard_ai,\
self._json_validator,\
self._manager,\
self._groupchat = self._create_agents(json_schema)
message = self._create_message(model_names, model_infos)
answer = self._admin.initiate_chat(self._manager, message=message) #, silent=True)
res = ""
# The json is not on answer.summary / last message since the last agent is json validator when successful
if answer.summary.find("exitcode: 0") > -1:
extractor = MarkdownCodeExtractor()
code_blocks = extractor.extract_code_blocks(answer.chat_history[-2]["content"])
if len(code_blocks) > 0:
quickboard = code_blocks[0].code
quickboard_items = json.loads(quickboard)
arranged_items = []
if layout_by_ai:
aiDesigner = QuickboardAIDesigner()
arranged_items = aiDesigner.arrange_items_with_ai(quickboard_items, screen_w, screen_h, cell_w, cell_h)
else:
arranged_items = self._arrange_items(quickboard_items)
res = json.dumps(arranged_items)
return res
class QuickboardAIDesigner:
_QUICKBOARD_DESIGNER_SYSTEM_MESSAGE = f"""
Your task is to design the layout of a dashboard from the given dashboard items.
## DASHBOARD ITEM TYPES
There are three kind of dashboard items, basic, list and chart.
A basic item is used for single value KPI, a list is used to display data in a tabular list
while a chart item is used to show data in a graphical chart.
All have the following attribute:
1. id: The id of the dashboard item.
2. type: Dashboard item type, 'basic' for basic items, 'list' for list items and 'chart' for chart items.
3. x_pos: The horizontal position on the grid (0 to 11, representing the block's position).
4. y_pos: The vertical position on the grid (measured in blocks, can be unlimited).
5. width: The width of the item.
6. height: The height of the item.
## LAYOUT
The dashboard layout is a grid system measured in square blocks.
It has 12 blocks for column width and unlimited cell rows.
The origin (0, 0) position is on the top left. The maximum position of the top row is (12, 0).
## REQUIREMENTS
1. Prioritize the arrangement of blocks to optimize space, i,e. no gaps between items.
2. Ensure that no blocks overlap and that all blocks are positioned within the defined grid limits.
3. Arrange the 'basic' items to fill the top rows.
4. Arrange the rest of the items to fill the rows after the 'basic' items.
## RULES
1. Do not change the 'id' and the 'type' of the dashboard items,
i.e, the 'id' and 'type' is a fixed pair. If you want to re-arrange the items, always use the same 'id' and 'type' pair.
2. Only edit these attribute 'x_pos', 'y_pos', 'width', 'height'.
3. Do not add nor remove dashboard items!
4. Fence your anwser with markdown json block.
5. Do not comment. Do not explain your answer.
6. Your answer will be validated by a bot, if your answer is not valid then you must fix it.
7. When fixing answer re-evaluate everything and do not give comment as it will create another error.
8. When fixing answer always reply with the the fixed json code block with every items.
"""
def __init__(self):
self._admin: UserProxyAgent = None
self._quickboard_ai: AssistantAgent = None
self._json_validator: UserProxyAgentForJsonValidation = None
self._groupchat: GroupChat = None
self._manager: GroupChatManager = None
def _create_agents(self, ui_json_schema):
admin = UserProxyAgent(
"admin",
description="The user who give tasks and questions.",
human_input_mode="NEVER",
is_termination_msg=lambda message: True, # Always True
code_execution_config=False,
)
quickboard_designer = AssistantAgent(
name="quickboard_designer",
description=f"AI that design the layout of dashboards.",
system_message=dedent(self._QUICKBOARD_DESIGNER_SYSTEM_MESSAGE),
human_input_mode="NEVER",
llm_config=DEFAULT_AUTOGEN_LLM_CONFIG,
)
ui_json_validator = UserProxyAgentForJsonValidation(
"ui_json_validator",
json_schema=ui_json_schema,
description="An bot that performs no other action than validating json (provided to it's quoted in json blocks).",
human_input_mode="NEVER",
)
def _speaker_selection_func(last_speaker: Agent, groupchat: GroupChat):
last_messages = groupchat.messages[-1]
next_speaker = admin
if last_speaker is admin:
next_speaker = quickboard_designer
elif last_speaker is quickboard_designer:
next_speaker = ui_json_validator
elif last_speaker is ui_json_validator:
# if last agent reply with invalid json then let it try again
if last_messages["content"].find("exitcode: -1") > -1:
next_speaker = quickboard_designer
elif last_messages["content"].strip() == "":
next_speaker = quickboard_designer
return next_speaker
groupchat = GroupChat(
agents=[admin, quickboard_designer, ui_json_validator],
messages=[],
max_round=5,
speaker_selection_method= _speaker_selection_func,
# send_introductions=True,
)
manager = GroupChatManager(
groupchat=groupchat,
name="chat_manager",
llm_config=DEFAULT_AUTOGEN_LLM_CONFIG
)
return (admin, quickboard_designer, ui_json_validator, manager, groupchat)
def _build_agent_parameters(self, quickboard_items):
layout_json_schema = copy.deepcopy(QUICKBOARD_LAYOUT_JSON_SCHEMA)
item_count = len(quickboard_items)
item_count_schema = {
"minItems": item_count,
"maxItems": item_count
}
layout_json_schema.update(item_count_schema)
item_id_type_schema = []
for item in quickboard_items:
id_type_schema = {
"if": {
"properties": {
"id": { "const": item["id"] }
}
},
"then": {
"properties": {
"type": { "const": item["type"] }
}
}
}
item_id_type_schema.append(id_type_schema)
layout_json_schema["items"]["allOf"] = item_id_type_schema
return layout_json_schema
def _create_message(self, quickboard_items, screen_w, screen_h, cell_w, cell_h):
layout_items = [[{
"id": o["id"],
"type": o["type"],
"x_pos": 0,
"y_pos": 0,
"width": 0,
"height": 0
}] for o in quickboard_items]
layout_items_count = len(layout_items)
layout_items_str = ", ".join([ f"{json.dumps(o)}\n" for o in layout_items])
message = f"""
Design dashboard layout for these {layout_items_count} items: {layout_items_str}.
The cell size for my screen is {cell_w} x {cell_h} (width x height).
My screen size is {screen_w} x {screen_h} (width x height).
Make it compact, for 'basic' items 1 block for its height is enough.
For 'list' and 'chart' items 3 blocks for its height are enough.
Put basic items first then list and chart items.
Do not change the 'id' and the 'type' of the items.
Remember there are {layout_items_count}, use them all, do not add any item nor remove any of them.
"""
return message
def arrange_items_with_ai(self, quickboard_items, screen_w, screen_h, cell_w, cell_h):
res = quickboard_items
for i, item in enumerate(quickboard_items, start=1):
item["id"] = i
json_schema = self._build_agent_parameters(quickboard_items)
self._admin,\
self._quickboard_ai,\
self._json_validator,\
self._manager,\
self._groupchat = self._create_agents(json_schema)
message = self._create_message(quickboard_items, screen_w, screen_h, cell_w, cell_h)
answer = self._admin.initiate_chat(self._manager, message=message) #, silent=True)
if answer.summary.find("exitcode: 0") > -1:
extractor = MarkdownCodeExtractor()
code_blocks = extractor.extract_code_blocks(answer.chat_history[-2]["content"])
if len(code_blocks) > 0:
layout = code_blocks[0].code
layout_items = json.loads(layout)
# merge item and layout
fin_items = []
for item in quickboard_items:
for layout in layout_items:
if layout["id"] == item["id"] and layout["type"] == item["type"]:
item.update(layout)
break
fin_items.append(item)
return fin_items
return res
+133
View File
@@ -0,0 +1,133 @@
# -*- coding: utf-8 -*-
import logging
import json
from jsonschema import validate
from odoo import _, fields, models
from odoo.exceptions import ValidationError
from .ai import QuickboardAiGenerator
from .ai.consts import QUICKBOARD_DATA_UI_JSON_SCHEMA, QUICKBOARD_BG_COLORS, QUICKBOARD_FG_COLORS
_logger = logging.getLogger(__name__)
class QuickboardGenerator(models.TransientModel):
_name = "quickboard.generator"
_description = "Generate quickbaord items with AI."
model_ids = fields.Many2many('ir.model', string='Model')
layout_by_ai = fields.Boolean("Layout by AI", default=False)
def action_generate_quickboard(self):
if len(self.model_ids.ids) < 1:
return {
'name': _('Generate Quickboard'),
'type': 'ir.actions.act_window',
'res_model': 'quickboard.generator',
'view_type': 'form',
'view_mode': 'form',
'res_id': self.id,
'target': 'new',
}
screen_width = self.env.context.get("screen_width")
screen_height = self.env.context.get("screen_height")
cell_width = self.env.context.get("cell_width")
cell_height = self.env.context.get("cell_height")
try:
gen = QuickboardAiGenerator(self.env)
quickboard = gen.generate_quickboard(
self.model_ids,
self.layout_by_ai,
screen_width,
screen_height,
cell_width,
cell_height
)
quickboard_json = json.loads(quickboard)
validate(quickboard_json, QUICKBOARD_DATA_UI_JSON_SCHEMA)
quickboard_item = self.env['quickboard.item']
items = quickboard_item.search([])
for o in items:
o.unlink()
for o in quickboard_json:
_logger.info(f"Creating quickboard item: {o}")
model = self.env["ir.model"].search([("model", "=", o["model"])])
value_field = self.env["ir.model.fields"].search([("model_id", "=", model.id), ("name", "=", o["value_field"])])
if not value_field.id:
raise Exception("AI generated invalid field: %s." % {o["value_field"]})
# Sometime AI choose the wrong aggregate function, we could return the result to AI with json schema validation.
# But, it would mean failing the result and making another attempt, the alternative is we just fix it here.
if value_field.ttype not in ['float', 'integer', 'monetary'] and o["aggregate_function"] != "count":
o["aggregate_function"] = "count"
vals = {
"name": o["name"],
"model_id": model.id,
"icon": o["icon"],
"type": o["type"],
"value_field_id": [value_field.id],
"aggregate_function": o["aggregate_function"],
"x_pos": o["x_pos"],
"y_pos": o["y_pos"],
"height": o["height"],
"width": o["width"]
}
if o["type"] == "basic":
text_color = 0
if o["text_color"] and o["text_color"] in QUICKBOARD_FG_COLORS:
text_color = QUICKBOARD_FG_COLORS.index(o["text_color"])
back_color = 0
if o["text_color"] and o["text_color"] in QUICKBOARD_BG_COLORS:
back_color = QUICKBOARD_BG_COLORS.index(o["text_color"])
vals.update({
"text_color": text_color,
"background_color": back_color,
})
elif o["type"] == "chart":
dimension_field = self.env["ir.model.fields"].search([("model_id", "=", model.id), ("name", "=", o["dimension_field"])])
if not dimension_field.id:
raise Exception("AI generated invalid field: %s." % {o["dimension_field"]})
vals.update({
"dimension_field_id": dimension_field.id,
"chart_type": o["chart_type"],
})
if "datetime_granularity" in o:
vals.update({
"datetime_granularity": o["datetime_granularity"]
})
elif o["type"] == "list":
dimension_field = self.env["ir.model.fields"].search([("model_id", "=", model.id), ("name", "=", o["dimension_field"])])
if not dimension_field.id:
raise Exception("AI generated invalid field: %s." % {o["dimension_field"]})
vals.update({
"dimension_field_id": dimension_field.id,
"list_row_limit": o["list_row_limit"]
})
if "datetime_granularity" in o:
vals.update({
"datetime_granularity": o["datetime_granularity"]
})
quickboard_item.with_context(ai_generation=True).create(vals)
except Exception as e:
_logger.error("Error generating quickboard", e)
raise ValidationError(_("Unfortunately the AI didn't generate valid quickboard. Please try again."))
for rec in self:
self.env["bus.bus"]._sendone("quickboard", "quickboard_updated", {})
return True
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="generate_quickboard_view" model="ir.ui.view">
<field name="name">quickboard.generator.view</field>
<field name="model">quickboard.generator</field>
<field name="type">form</field>
<field name="arch" type="xml">
<form>
<div class="alert alert-danger" role="alert">
<i class="fa fa-exclamation-triangle"/> All quickboard items will be removed.
</div>
<group>
<field name="model_ids"
options="{'no_create_edit':True,'no_create': True}"
domain="[('transient', '=', False)]"
widget="many2many_tags"/>
</group>
<group>
<field name="layout_by_ai" />
</group>
<div class="alert alert-info" role="alert" invisible="not layout_by_ai">
<i class="fa fa-info-circle"/> The result might not what you've expected.
</div>
<footer>
<button name="action_generate_quickboard" string="Ok" type="object" default_focus="1" class="oe_highlight"/>
<button string="Cancel" special="cancel"/>
</footer>
</form>
</field>
</record>
</data>
</odoo>