|
| 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} |
0 commit comments