Skip to content

[16.0][IMP] mis_builder: introduce annotation in reports #676

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: 16.0
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions mis_builder/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"date_range", # OCA/server-ux
],
"data": [
"security/res_groups.xml",
"wizard/mis_builder_dashboard.xml",
"views/mis_report.xml",
"views/mis_report_instance.xml",
Expand Down
1 change: 1 addition & 0 deletions mis_builder/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@
from . import aep
from . import mis_kpi_data
from . import prorata_read_group_mixin
from . import mis_report_instance_annotation
43 changes: 39 additions & 4 deletions mis_builder/models/kpimatrix.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,7 @@ def label(self):

@property
def row_id(self):
if not self.account_id:
return self.kpi.name
else:
return f"{self.kpi.name}:{self.account_id}"
self._matrix._make_row_id(self.kpi.id, self.account_id)

def iter_cell_tuples(self, cols=None):
if cols is None:
Expand Down Expand Up @@ -143,6 +140,7 @@ def __init__(
self.style_props = style_props
self.drilldown_arg = drilldown_arg
self.val_type = val_type
self.cell_id = KpiMatrix._pack_cell_id(self)


class KpiMatrix:
Expand Down Expand Up @@ -524,16 +522,53 @@ def as_dict(self):
else:
val = cell.val
col_data = {
"cell_id": cell.cell_id,
"val": val,
"val_r": cell.val_rendered,
"val_c": cell.val_comment,
"style": self._style_model.to_css_style(
cell.style_props, no_indent=True
),
# notes can not be added on 'details by account' lines
"can_be_annotated": not cell.row.account_id,
}
if cell.drilldown_arg:
col_data["drilldown_arg"] = cell.drilldown_arg
row_data["cells"].append(col_data)
body.append(row_data)

return {"header": header, "body": body}

# Logic to convert semantic coordinates (period, kpi, subkpi)
# to visual coordinates (cell id) and back. The rendering logic musn't know
# about semantic concepts such as periods and kpis. Having these well identified
# methods allow us to easily spot where the conversion between the rendering and
# semantic domain occur.

@classmethod
def _make_row_id(cls, kpi_id: int, account_id: int | None) -> str:
return f"{kpi_id}:{account_id or ''}"

@classmethod
def _make_cell_id(
cls, kpi_id: int, account_id: int | None, period_id: int, subkpi_id: int | None
) -> str:
return f"{kpi_id}#{account_id or ''}#{period_id}#{subkpi_id or ''}"

@classmethod
def _pack_cell_id(cls, cell: KpiMatrixCell) -> str:
return cls._make_cell_id(
cell.row.kpi.id,
cell.row.account_id,
cell.subcol.col.key,
cell.subcol.subkpi and cell.subcol.subkpi.id,
)

@classmethod
def _unpack_cell_id(cls, cell_id: str) -> tuple[int, int | None, int, int | None]:
kpi_id, account_id, col_key, subkpi_id = cell_id.split("#")
kpi_id = int(kpi_id)
account_id = int(account_id) if account_id else None
period_id = int(col_key)
subkpi_id = int(subkpi_id) if subkpi_id else None
return kpi_id, account_id, period_id, subkpi_id
68 changes: 67 additions & 1 deletion mis_builder/models/mis_report_instance.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

from .aep import AccountingExpressionProcessor as AEP
from .expression_evaluator import ExpressionEvaluator
from .kpimatrix import KpiMatrix

_logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -580,6 +581,12 @@ def _compute_pivot_date(self):
string="Filter box search view",
help="Search view to customize the filter box in the report widget.",
)
user_can_read_annotation = fields.Boolean(
compute="_compute_user_can_read_annotation",
)
user_can_edit_annotation = fields.Boolean(
compute="_compute_user_can_edit_annotation",
)

@api.depends("report_id.move_lines_source")
def _compute_widget_search_view_id(self):
Expand Down Expand Up @@ -877,7 +884,44 @@ def _compute_matrix(self):
def compute(self):
self.ensure_one()
kpi_matrix = self._compute_matrix()
return kpi_matrix.as_dict()
ret = kpi_matrix.as_dict()

ret["notes"] = self.get_notes_by_cell_id()
return ret

def get_notes_by_cell_id(self) -> dict:
self.ensure_one()
if not self.user_can_read_annotation:
return {}

annotations = self.env["mis.report.instance.annotation"].search(
[
("period_id", "in", self.period_ids.ids),
]
)
annotation_context = self._get_annotation_context()
annotations = annotations.filtered(
lambda rec: rec.annotation_context == annotation_context
)

annotations_sorted = sorted(
annotations,
key=lambda r: (
r.kpi_id.sequence,
r.period_id.sequence,
r.subkpi_id.sequence,
),
)

return {
KpiMatrix._make_cell_id(
annotation.kpi_id.id,
False,
annotation.period_id.id,
annotation.subkpi_id and annotation.subkpi_id.id,
): {"text": annotation.note, "sequence": sequence}
for sequence, annotation in enumerate(annotations_sorted, 1)
}

@api.model
def _get_drilldown_views_and_orders(self):
Expand Down Expand Up @@ -940,3 +984,25 @@ def _get_drilldown_action_name(self, arg):
return f"{kpi.description} - {account.display_name} - {period.display_name}"
else:
return f"{kpi.description} - {period.display_name}"

def _get_annotation_context(self):
"""Return the context used to filter annotation linked to this instance."""
self.ensure_one()
annotation_context = {}
if query_company_ids := self.query_company_ids.ids:
# sort ids to make the comparaison easier
annotation_context["query_company_ids"] = sorted(query_company_ids)

return annotation_context

@api.depends_context("uid")
def _compute_user_can_read_annotation(self):
self.user_can_read_annotation = self.env.user.has_group(
"mis_builder.group_read_annotation"
)

@api.depends_context("uid")
def _compute_user_can_edit_annotation(self):
self.user_can_edit_annotation = self.env.user.has_group(
"mis_builder.group_edit_annotation"
)
113 changes: 113 additions & 0 deletions mis_builder/models/mis_report_instance_annotation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# Copyright 2025 ACSONE SA/NV
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).


from odoo import _, api, fields, models
from odoo.exceptions import AccessError

from .kpimatrix import KpiMatrix


class MisReportInstanceAnnotation(models.Model):
_name = "mis.report.instance.annotation"
_description = "Mis Report Instance Annotation"

period_id = fields.Many2one(
comodel_name="mis.report.instance.period",
ondelete="cascade",
required=True,
)
kpi_id = fields.Many2one(
comodel_name="mis.report.kpi",
ondelete="cascade",
required=True,
)
subkpi_id = fields.Many2one(
comodel_name="mis.report.subkpi",
ondelete="cascade",
)
note = fields.Char()
annotation_context = fields.Json(
help="""
Context used when adding annotation
"""
)

def init(self):
self.env.cr.execute(
"""
CREATE INDEX IF NOT EXISTS
mis_report_instance_annotation_period_id_kpi_id_subkpi_id_idx
ON mis_report_instance_annotation(period_id,kpi_id,subkpi_id);
"""
)

@api.model
def _get_first_matching_annotation(self, cell_id, instance_id):
"""
Return first annoation
matching exactly the period,kpi,subkpi and annotation context
"""

kpi_id, _, period_id, subkpi_id = KpiMatrix._unpack_cell_id(cell_id)

annotations = self.env["mis.report.instance.annotation"].search(
[
("period_id", "=", period_id),
("kpi_id", "=", kpi_id),
("subkpi_id", "=", subkpi_id),
],
)
annotation_context = (
self.env["mis.report.instance"]
.browse(instance_id)
._get_annotation_context()
)
annotation = fields.first(
annotations.filtered(
lambda rec: rec.annotation_context == annotation_context
)
)
return annotation

@api.model
def set_annotation(self, cell_id, instance_id, note):
if (
not self.env["mis.report.instance"]
.browse(instance_id)
.user_can_edit_annotation
):
raise AccessError(_("You do not have the rights to edit annotations"))

annotation = self._get_first_matching_annotation(cell_id, instance_id)

if annotation:
annotation.note = note
else:
kpi_id, _account_id, period_id, subkpi_id = KpiMatrix._unpack_cell_id(
cell_id
)
self.env["mis.report.instance.annotation"].create(
{
"period_id": period_id,
"kpi_id": kpi_id,
"subkpi_id": subkpi_id,
"note": note,
"annotation_context": self.env["mis.report.instance"]
.browse(instance_id)
._get_annotation_context(),
}
)

@api.model
def remove_annotation(self, cell_id, instance_id):
if (
not self.env["mis.report.instance"]
.browse(instance_id)
.user_can_edit_annotation
):
raise AccessError(_("You do not have the rights to edit annotations"))

annotation = self._get_first_matching_annotation(cell_id, instance_id)
if annotation:
annotation.unlink()
23 changes: 23 additions & 0 deletions mis_builder/report/mis_report_instance_qweb.xml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
<t t-foreach="docs" t-as="o">
<t t-call="web.internal_layout">
<t t-set="matrix" t-value="o._compute_matrix()" />
<t t-set="notes" t-value="o.get_notes_by_cell_id()" />
<t t-set="style_obj" t-value="o.env['mis.report.style']" />
<div class="page">
<h3>
Expand Down Expand Up @@ -97,12 +98,34 @@
<t
t-out="cell and cell.val_rendered or ''"
/>
<span
class="oe_mis_builder_footnote"
t-if="cell"
>
<t
t-out="notes.get(cell.cell_id,{}).get('sequence','')"
/>
</span>
</div>
</t>
</div>
</t>
</div>
</div>
<!-- Foot notes -->
<div class="oe_mis_builder_footnote_div">
<table class="oe_mis_builder_footnote_table">
<t
t-foreach="sorted(notes.values(),key=lambda r:r['sequence'])"
t-as="note"
>
<tr>
<td><t t-out="note['sequence']" />. </td>
<td><t t-out="note['text']" /></td>
</tr>
</t>
</table>
</div>
</div>
</t>
</t>
Expand Down
12 changes: 11 additions & 1 deletion mis_builder/report/mis_report_instance_xlsx.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from collections import defaultdict
from datetime import datetime

from odoo import _, fields, models
from odoo import _, api, fields, models

from ..models.accounting_none import AccountingNone
from ..models.data_error import DataError
Expand All @@ -26,9 +26,18 @@ class MisBuilderXlsx(models.AbstractModel):
_description = "MIS Builder XLSX report"
_inherit = "report.report_xlsx.abstract"

@api.model
def _mis_builder_add_annotation(self, sheet, cell, row_pos, col_pos, notes):
"""
Add anotation as a comment on cell in .xls
"""
if cell and (annotation := notes.get(cell.cell_id, {}).get("text")):
sheet.write_comment(row_pos, col_pos, annotation)

def generate_xlsx_report(self, workbook, data, objects):
# get the computed result of the report
matrix = objects._compute_matrix()
notes = objects.get_notes_by_cell_id()
style_obj = self.env["mis.report.style"]

# create worksheet
Expand Down Expand Up @@ -120,6 +129,7 @@ def generate_xlsx_report(self, workbook, data, objects):
)
for cell in row.iter_cells():
col_pos += 1
self._mis_builder_add_annotation(sheet, cell, row_pos, col_pos, notes)
if not cell or cell.val is AccountingNone:
# TODO col/subcol format
sheet.write(row_pos, col_pos, "", row_format)
Expand Down
2 changes: 2 additions & 0 deletions mis_builder/security/ir.model.access.csv
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,5 @@ access_mis_report_subreport,access_mis_report_subreport,model_mis_report_subrepo
manage_mis_report_style,access_mis_report_style,model_mis_report_style,account.group_account_manager,1,1,1,1
access_mis_report_style,access_mis_report_style,model_mis_report_style,base.group_user,1,0,0,0
access_add_to_dashboard_wizard,access_add_to_dashboard_wizard,model_add_mis_report_instance_dashboard_wizard,base.group_user,1,1,1,0
access_read_mis_report_annotation, access_read_mis_report_annotation,model_mis_report_instance_annotation,mis_builder.group_read_annotation,1,0,0,0
access_edit_mis_report_annotation, access_edit_mis_report_annotation,model_mis_report_instance_annotation,mis_builder.group_edit_annotation,1,1,1,1
17 changes: 17 additions & 0 deletions mis_builder/security/res_groups.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record model="res.groups" id="group_read_annotation">
<field name="name">MIS Report: view annotations</field>
</record>
<record model="res.groups" id="group_edit_annotation">
<field name="name">MIS Report: add annotations</field>
<field
name="implied_ids"
eval="[Command.link(ref('mis_builder.group_read_annotation'))]"
/>
<field
name="users"
eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"
/>
</record>
</odoo>
Loading
Loading