Skip to content

Commit 9cf5c3e

Browse files
dnplkndllclaude
andcommitted
[IMP] spreadsheet_oca: add named input parameters with domain substitution
Adds spreadsheet.input_param model for mapping named parameters to specific spreadsheet cells. Values are synced from the spreadsheet JSON and injected into refresh schedule domain templates via %(name)s substitution, enabling parameterized pivot queries. Overrides _get_param_dict() on refresh.schedule to wire up the sync-and-substitute pipeline. Includes JSON-RPC controller for client-side access, security rules, demo data, and tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f2a7d1e commit 9cf5c3e

12 files changed

Lines changed: 811 additions & 0 deletions

spreadsheet_oca/__manifest__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"security/ir.model.access.csv",
1616
"views/spreadsheet_spreadsheet.xml",
1717
"views/spreadsheet_refresh_schedule_views.xml",
18+
"views/spreadsheet_input_param_views.xml",
1819
"data/mail_templates.xml",
1920
"data/spreadsheet_spreadsheet_import_mode.xml",
2021
"wizards/spreadsheet_select_row_number.xml",
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
from . import main
2+
from . import spreadsheet_input_params
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Copyright 2025 Ledo Enterprises LLC
2+
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
3+
"""
4+
JSON endpoint for spreadsheet input parameters.
5+
6+
Provides a lightweight API so that a future JS plugin can read the current
7+
named-parameter values without having to parse spreadsheet_raw itself.
8+
"""
9+
10+
from odoo.http import Controller, request, route
11+
12+
13+
class SpreadsheetInputParamsController(Controller):
14+
@route(
15+
"/spreadsheet/input_params/<int:spreadsheet_id>",
16+
type="json",
17+
auth="user",
18+
)
19+
def get_input_params(self, spreadsheet_id):
20+
"""Return {name: current_value} for all active parameters."""
21+
spreadsheet = request.env["spreadsheet.spreadsheet"].browse(spreadsheet_id)
22+
if not spreadsheet.exists():
23+
return {"error": "Spreadsheet not found."}
24+
spreadsheet.check_access("read")
25+
params = request.env["spreadsheet.input_param"].search(
26+
[
27+
("spreadsheet_id", "=", spreadsheet_id),
28+
("active", "=", True),
29+
]
30+
)
31+
return {p.name: p.current_value for p in params}

spreadsheet_oca/demo/spreadsheet_spreadsheet.xml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,4 +99,32 @@
9999
<field name="interval_type">weeks</field>
100100
<field name="notify_partner_ids" eval="[(4, ref('base.partner_admin'))]" />
101101
</record>
102+
103+
<!-- ════════════════════════════════════════════════════════════════════
104+
Input Parameters
105+
════════════════════════════════════════════════════════════════════ -->
106+
107+
<record id="demo_param_start_date" model="spreadsheet.input_param">
108+
<field name="name">start_date</field>
109+
<field name="spreadsheet_id" ref="demo_spreadsheet" />
110+
<field name="cell_ref">Parameters!B2</field>
111+
<field name="description">Start of reporting period</field>
112+
<field name="current_value">2026-01-01</field>
113+
</record>
114+
115+
<record id="demo_param_end_date" model="spreadsheet.input_param">
116+
<field name="name">end_date</field>
117+
<field name="spreadsheet_id" ref="demo_spreadsheet" />
118+
<field name="cell_ref">Parameters!B3</field>
119+
<field name="description">End of reporting period</field>
120+
<field name="current_value">2026-12-31</field>
121+
</record>
122+
123+
<record id="demo_param_growth" model="spreadsheet.input_param">
124+
<field name="name">growth_rate</field>
125+
<field name="spreadsheet_id" ref="demo_spreadsheet" />
126+
<field name="cell_ref">Parameters!B4</field>
127+
<field name="description">Expected annual growth rate</field>
128+
<field name="current_value">0.15</field>
129+
</record>
102130
</odoo>

spreadsheet_oca/models/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@
77
from . import spreadsheet_spreadsheet_import_mode
88
from . import pivot_data
99
from . import spreadsheet_refresh_schedule
10+
from . import spreadsheet_input_param
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
# Copyright 2025 Ledo Enterprises LLC
2+
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
3+
"""
4+
Named input parameters.
5+
6+
Users mark specific cells as named "input parameters." A parameter stores
7+
the cell's current value so that:
8+
9+
1. Server-side domain substitution can reference it during scheduled refresh
10+
(e.g. ``[("date", ">=", "%(start_date)s")]``).
11+
2. A future JS side-panel can re-query pivot data sources in real time when
12+
an input cell changes (the JSON endpoint is already wired up).
13+
14+
This is the Python backend layer only. The o-spreadsheet JS plugin work
15+
required for live client-side re-query is a planned follow-on.
16+
"""
17+
18+
import logging
19+
import re
20+
21+
from odoo import _, api, fields, models
22+
from odoo.exceptions import ValidationError
23+
24+
from .cell_ref import parse_cell_key, read_cell_value
25+
26+
_logger = logging.getLogger(__name__)
27+
28+
# Valid parameter names: start with a lowercase letter, then lowercase letters,
29+
# digits, or underscores. Mirrors Python %(name)s identifier conventions.
30+
_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*$")
31+
32+
33+
class SpreadsheetInputParam(models.Model):
34+
_name = "spreadsheet.input_param"
35+
_description = "Spreadsheet Input Parameter"
36+
_inherit = ["mail.thread"]
37+
_order = "spreadsheet_id, name"
38+
39+
name = fields.Char(
40+
required=True,
41+
tracking=True,
42+
help=(
43+
"Identifier used in domain templates as %(name)s.\n"
44+
"Must start with a lowercase letter and contain only lowercase letters, "
45+
"digits, and underscores."
46+
),
47+
)
48+
spreadsheet_id = fields.Many2one(
49+
"spreadsheet.spreadsheet",
50+
required=True,
51+
ondelete="cascade",
52+
index=True,
53+
)
54+
cell_ref = fields.Char(
55+
string="Cell Reference",
56+
required=True,
57+
tracking=True,
58+
help=(
59+
"Cell to read the parameter value from.\n"
60+
"Use a bare reference (e.g. B3) or include the sheet name "
61+
"(e.g. Sheet1!B3)."
62+
),
63+
)
64+
description = fields.Char(
65+
help="Optional human note explaining what this parameter controls.",
66+
)
67+
active = fields.Boolean(default=True, tracking=True)
68+
69+
# ── Synced value ──────────────────────────────────────────────────────────
70+
current_value = fields.Char(
71+
readonly=True,
72+
copy=False,
73+
help="Last value read from the spreadsheet cell.",
74+
)
75+
last_synced = fields.Datetime(
76+
readonly=True,
77+
copy=False,
78+
)
79+
80+
# ── Unique name per spreadsheet ───────────────────────────────────────────
81+
_sql_constraints = [
82+
(
83+
"unique_name_per_spreadsheet",
84+
"UNIQUE(spreadsheet_id, name)",
85+
"A parameter with this name already exists for this spreadsheet.",
86+
),
87+
]
88+
89+
# ── Constraints ───────────────────────────────────────────────────────────
90+
91+
@api.constrains("cell_ref")
92+
def _check_cell_ref(self):
93+
for rec in self:
94+
if not rec.cell_ref:
95+
continue
96+
_sheet, col, row = parse_cell_key(rec.cell_ref.strip())
97+
if col is None:
98+
raise ValidationError(
99+
_("Cell reference %(ref)r is not valid. Use 'B3' or 'Sheet1!B3'.")
100+
% {"ref": rec.cell_ref}
101+
)
102+
103+
@api.constrains("name")
104+
def _check_name(self):
105+
for rec in self:
106+
if rec.name and not _NAME_RE.match(rec.name):
107+
raise ValidationError(
108+
_(
109+
"Parameter name %(name)r is not valid. "
110+
"It must start with a lowercase letter and contain only "
111+
"lowercase letters, digits, and underscores."
112+
)
113+
% {"name": rec.name}
114+
)
115+
116+
# ── Sync logic ────────────────────────────────────────────────────────────
117+
118+
def _sync_from_spreadsheet(self):
119+
"""Read this parameter's cell from spreadsheet_raw and store the value."""
120+
self.ensure_one()
121+
raw = self.spreadsheet_id.sudo().spreadsheet_raw or {}
122+
value = read_cell_value(raw, self.cell_ref.strip())
123+
now = fields.Datetime.now()
124+
if value is not None:
125+
self.write({"current_value": str(value), "last_synced": now})
126+
else:
127+
self.write({"last_synced": now})
128+
129+
def action_sync_now(self):
130+
"""Manually trigger a sync for this parameter."""
131+
self.ensure_one()
132+
self._sync_from_spreadsheet()
133+
134+
@api.model
135+
def _sync_all_for_spreadsheet(self, spreadsheet_id):
136+
"""Sync all active input parameters for the given spreadsheet record ID."""
137+
params = self.search(
138+
[
139+
("spreadsheet_id", "=", spreadsheet_id),
140+
("active", "=", True),
141+
]
142+
)
143+
for param in params:
144+
try:
145+
param._sync_from_spreadsheet()
146+
except Exception:
147+
_logger.exception(
148+
"Failed to sync input param %s (%s) for spreadsheet %s",
149+
param.id,
150+
param.name,
151+
spreadsheet_id,
152+
)
153+
154+
155+
class SpreadsheetRefreshScheduleInputParams(models.Model):
156+
"""Override _get_param_dict to provide input parameter values."""
157+
158+
_inherit = "spreadsheet.refresh.schedule"
159+
160+
def _get_param_dict(self, spreadsheet):
161+
"""Sync all input params and return ``{name: value}`` for substitution."""
162+
self.env["spreadsheet.input_param"]._sync_all_for_spreadsheet(spreadsheet.id)
163+
input_params = self.env["spreadsheet.input_param"].search(
164+
[("spreadsheet_id", "=", spreadsheet.id), ("active", "=", True)]
165+
)
166+
return {p.name: p.current_value or "" for p in input_params}

spreadsheet_oca/models/spreadsheet_spreadsheet.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,28 @@ def _compute_related_count(self, comodel, field_name, extra_domain=None):
7676
for rec in self:
7777
rec[field_name] = count_map.get(rec.id, 0)
7878

79+
# ── Input Parameters ────────────────────────────────────────────────────
80+
input_param_count = fields.Integer(
81+
compute="_compute_input_param_count", string="Input Parameters"
82+
)
83+
84+
def _compute_input_param_count(self):
85+
self._compute_related_count("spreadsheet.input_param", "input_param_count")
86+
87+
def action_open_input_params(self):
88+
self.ensure_one()
89+
return {
90+
"type": "ir.actions.act_window",
91+
"name": _("Input Parameters"),
92+
"res_model": "spreadsheet.input_param",
93+
"view_mode": "list,form",
94+
"domain": [("spreadsheet_id", "=", self.id)],
95+
"context": {
96+
"default_spreadsheet_id": self.id,
97+
"search_default_active": 1,
98+
},
99+
}
100+
79101
@api.depends("name")
80102
def _compute_filename(self):
81103
for record in self:

spreadsheet_oca/security/ir.model.access.csv

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,5 @@ access_spreadsheet_spreadsheet_tag,access_spreadsheet_spreadsheet_tag,model_spre
88
access_spreadsheet_spreadsheet_manager_tag,access_spreadsheet_spreadsheet_manager_tag,model_spreadsheet_spreadsheet_tag,spreadsheet_oca.group_manager,1,1,1,1
99
access_spreadsheet_refresh_schedule_user,access_spreadsheet_refresh_schedule_user,model_spreadsheet_refresh_schedule,base.group_user,1,0,0,0
1010
access_spreadsheet_refresh_schedule_manager,access_spreadsheet_refresh_schedule_manager,model_spreadsheet_refresh_schedule,spreadsheet_oca.group_manager,1,1,1,1
11+
access_spreadsheet_input_param_user,access_spreadsheet_input_param_user,model_spreadsheet_input_param,base.group_user,1,0,0,0
12+
access_spreadsheet_input_param_manager,access_spreadsheet_input_param_manager,model_spreadsheet_input_param,spreadsheet_oca.group_manager,1,1,1,1

spreadsheet_oca/security/security.xml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,4 +85,23 @@
8585
<field name="groups" eval="[(4, ref('spreadsheet_oca.group_manager'))]" />
8686
<field name="domain_force">[(1, '=', 1)]</field>
8787
</record>
88+
89+
<record model="ir.rule" id="input_param_user_rule">
90+
<field name="name">Input Param: follow spreadsheet access</field>
91+
<field name="model_id" ref="model_spreadsheet_input_param" />
92+
<field name="groups" eval="[(4, ref('spreadsheet_oca.group_user'))]" />
93+
<field name="domain_force">[
94+
'|', '|', '|',
95+
('spreadsheet_id.owner_id', '=', user.id),
96+
('spreadsheet_id.contributor_ids', '=', user.id),
97+
('spreadsheet_id.contributor_group_ids', 'in', user.groups_id.ids),
98+
('spreadsheet_id.reader_ids', '=', user.id),
99+
]</field>
100+
</record>
101+
<record model="ir.rule" id="input_param_manager_rule">
102+
<field name="name">Input Param: manager full access</field>
103+
<field name="model_id" ref="model_spreadsheet_input_param" />
104+
<field name="groups" eval="[(4, ref('spreadsheet_oca.group_manager'))]" />
105+
<field name="domain_force">[(1, '=', 1)]</field>
106+
</record>
88107
</odoo>

spreadsheet_oca/tests/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
from . import test_pivot_data
22
from . import test_refresh_schedule
3+
from . import test_input_param

0 commit comments

Comments
 (0)