update
This commit is contained in:
@@ -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:
|
||||
|
||||
[](https://youtu.be/LfxlUN9pikI)
|
||||
|
||||
[](https://youtu.be/y_prYVEp9mk)
|
||||
@@ -0,0 +1,3 @@
|
||||
from . import controllers
|
||||
from . import models
|
||||
from . import wizard
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import main
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import quickboard_item
|
||||
from . import res_users_settings
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
@@ -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 |
@@ -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 |
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import quickboard_generator
|
||||
@@ -0,0 +1,7 @@
|
||||
from .json_validator_agent import UserProxyAgentForJsonValidation
|
||||
from .quickboard_ai_generator import QuickboardAiGenerator
|
||||
|
||||
__all__ = [
|
||||
"UserProxyAgentForJsonValidation",
|
||||
"QuickboardAiGenerator"
|
||||
]
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user