diff --git a/spreadsheet_field_linking/README.rst b/spreadsheet_field_linking/README.rst new file mode 100644 index 00000000..29c8be36 --- /dev/null +++ b/spreadsheet_field_linking/README.rst @@ -0,0 +1,227 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +========================= +Spreadsheet Field Linking +========================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:db0f2e78d1a2d21ca65301e8be203f65b5fd209f0665491c51d5247f190c7b4e + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fspreadsheet-lightgray.png?logo=github + :target: https://github.com/OCA/spreadsheet/tree/19.0/spreadsheet_field_linking + :alt: OCA/spreadsheet +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/spreadsheet-19-0/spreadsheet-19-0-spreadsheet_field_linking + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/spreadsheet&target_branch=19.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module turns an Odoo spreadsheet into a tool that can be used to +push data to a record. + +You link a spreadsheet to a record (a sale order, a project, a +partner...), point some of its cells at the fields of that record, and +then write the values you computed in the spreadsheet back onto the +record in one click. + +A typical use is building a pricing or estimation sheet: you do the +maths in the spreadsheet, and the results land on the right fields of +the order without any copy-paste. Cells can target a field of the linked +record, a field of a related record, or a field on the lines of the +record. + +The links are stored inside the spreadsheet itself, so they are kept +when the file is saved and reopened, and they follow their cells when +rows are moved. + +This is a foundation module: it adds the linking tools to every Odoo +spreadsheet but does not, by itself, tie any particular business +document to a spreadsheet. Other modules build on it could offer +ready-made spreadsheets for their own models. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +Everything happens from inside the spreadsheet editor, through two +menus: the **File** menu at the top left, and the **cell menu** you get +by right-clicking a cell. + +Link the spreadsheet to a record +-------------------------------- + +Open any spreadsheet and go to **File → Manage linked record**. This +entry is always available. + +|image1| + +A side panel opens on the right: + +- **Model** — pick the kind of record you want to link (Sale Order, + Partner...). Only models you are allowed to edit are offered. +- **Record** — pick the specific record. +- **Link** — confirms the choice and links the spreadsheet to that + record. +- **Unlink** — removes the link (shown only once a record is linked). + +The top of the panel always shows which record is currently linked, if +any. + +|image2| + +Link a cell to a field +---------------------- + +Once the spreadsheet is linked, you can point cells at the record's +fields. Right-click the cell that holds the value you want to send and +choose **Link to field** (or, on a cell that is already linked, **Edit +field link**). The same entry is also available under **File → Link to +field**. + +|image3| + +A side panel opens for that cell: + +- **Field to link** — choose the field that should receive the cell's + value. You can follow relations to reach a field of a related record, + or a field on the record's lines. +- **Line (position)** — when the field lives on lines, tell the module + which line to write to by its position (1 for the first line, 2 for + the second...). + +A short **Saved** confirmation appears each time a change is stored. + +Writing to lines, and creating new ones +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Many records hold a list of lines — a sale order, for example, has its +order lines. You can write to those lines by their position, and you can +also add brand-new ones: + +- **To update a line that already exists**, set its position (1, 2, + 3...) to one that is already there. +- **To add a new line**, set a position just past the last one. + Everything you mapped to that same position is gathered together into + a single new line, so before you write, map every value the new line + needs (description, quantity, price...) to that same position. + +Positions follow the order the lines appear on the record's form: +position 1 is the first line, 2 the second, and so on. Because of this, +a position points at a *slot*, not at one particular line — if you +reorder, add, or remove lines, a position may end up aiming at a +different line than before, so it is worth a quick check after +rearranging. + +A couple of limitations, and why they are there +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +To avoid making a mess, the module only ever *creates* new lines that +belong directly to the record you linked — the record's own list of +lines, like the lines of the sale order itself. Two things it will +deliberately not do, and the reason for each: + +- **It won't invent items that exist on their own.** Some fields don't + hold lines that belong to the record; instead they point to things + that live independently and can be shared by many records at once — + think of the tags on a contact, or the product picked on a line. If + one of those is already there, the module is glad to update a value on + it. But it will not create a new one for you: doing that could quietly + leave duplicates or half-finished entries scattered in other parts of + Odoo that the rest of your data relies on. To stay safe, it only + reuses what already exists in those spots. + +- **It won't build a line hidden deep inside another line.** The module + only adds lines one step down, directly on the record you linked. To + create something nested further in — a line tucked inside another line + — it would first have to invent the in-between lines that aren't there + yet, filling in details you never entered. Rather than guess, it + simply leaves those untouched. + +So, in plain terms: the module creates a new line only where it can do +so safely and without guessing — directly on the record you linked. +Everywhere else it updates what is already there, but never creates. + +|image4| + +Remove a link from a cell +------------------------- + +To unlink one or more cells, select them, right-click and choose +**Remove field link**. This entry appears only when at least one cell in +the selection is linked. + +Write the values back to the record +----------------------------------- + +When the spreadsheet is linked to a record you can edit, a **Write to +record** button appears in the top bar (it shows the record's name once +one is linked, e.g. *Write to SO0042*). Click it to send every linked +cell's current value to its field on the record at once. + +|image5| + +.. |image1| image:: https://raw.githubusercontent.com/OCA/spreadsheet/19.0/spreadsheet_field_linking/static/description/file_manage_linked_record.png +.. |image2| image:: https://raw.githubusercontent.com/OCA/spreadsheet/19.0/spreadsheet_field_linking/static/description/manage_linked_record_panel.png +.. |image3| image:: https://raw.githubusercontent.com/OCA/spreadsheet/19.0/spreadsheet_field_linking/static/description/cell_menu_link_to_field.png +.. |image4| image:: https://raw.githubusercontent.com/OCA/spreadsheet/19.0/spreadsheet_field_linking/static/description/field_link_panel.png +.. |image5| image:: https://raw.githubusercontent.com/OCA/spreadsheet/19.0/spreadsheet_field_linking/static/description/write_to_record_button.png + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* arielbarreiros96 + +Contributors +------------ + +- `arielbarreiros96 `__ + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/spreadsheet `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/spreadsheet_field_linking/__init__.py b/spreadsheet_field_linking/__init__.py new file mode 100644 index 00000000..0650744f --- /dev/null +++ b/spreadsheet_field_linking/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/spreadsheet_field_linking/__manifest__.py b/spreadsheet_field_linking/__manifest__.py new file mode 100644 index 00000000..4a92448d --- /dev/null +++ b/spreadsheet_field_linking/__manifest__.py @@ -0,0 +1,24 @@ +# Copyright 2026 arielbarreiros96 +# License AGPL-3.0 or later (https://www.gnu.org/licenses/AGPL-3.0). +{ + "name": "Spreadsheet Field Linking", + "version": "19.0.1.0.0", + "summary": "Link spreadsheet cells to record fields and write their values back", + "author": "arielbarreiros96, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/spreadsheet", + "license": "AGPL-3", + "category": "Productivity", + "depends": ["spreadsheet_oca"], + "data": [], + "assets": { + "spreadsheet.o_spreadsheet": [ + "spreadsheet_field_linking/static/src/field_linking/**/*.js", + "spreadsheet_field_linking/static/src/field_linking/**/*.xml", + ], + "web.assets_unit_tests": [ + "spreadsheet_field_linking/static/tests/**/*", + ], + }, + "installable": True, + "application": False, +} diff --git a/spreadsheet_field_linking/models/__init__.py b/spreadsheet_field_linking/models/__init__.py new file mode 100644 index 00000000..e9340e0a --- /dev/null +++ b/spreadsheet_field_linking/models/__init__.py @@ -0,0 +1,2 @@ +from . import spreadsheet_linkable +from . import spreadsheet_spreadsheet diff --git a/spreadsheet_field_linking/models/spreadsheet_linkable.py b/spreadsheet_field_linking/models/spreadsheet_linkable.py new file mode 100644 index 00000000..236eddd8 --- /dev/null +++ b/spreadsheet_field_linking/models/spreadsheet_linkable.py @@ -0,0 +1,270 @@ +import re +from datetime import datetime, timedelta + +from odoo import Command, fields, models + + +def _to_float(value): + try: + return float(value) + except (TypeError, ValueError): + return None + + +def _to_bool(value): + if isinstance(value, str): + return value.strip().lower() not in ("", "0", "false", "no", "n") + return bool(value) + + +def _to_datetime(value): + number = _to_float(value) + if number is not None: + # o-spreadsheet, like Excel, counts dates as serial days from 1899-12-30. + return datetime(1899, 12, 30) + timedelta(days=number) + try: + return fields.Datetime.to_datetime(value) + except (TypeError, ValueError): + return None + + +def _to_ids(value): + items = value if isinstance(value, (list, tuple)) else re.split(r"\D+", str(value)) + return [int(n) for n in map(_to_float, items) if n is not None and n.is_integer()] + + +def _cast(field, value): + ftype = field.type + if value is None or value == "": + # A mapped cell left empty clears the field: the mapping is deliberate. + return [Command.clear()] if ftype in ("one2many", "many2many") else False + number = _to_float(value) + whole = number if number is not None and number.is_integer() else None + if ftype in ("float", "monetary"): + return number + if ftype == "integer": + return None if number is None else int(number) + if ftype == "boolean": + return _to_bool(value) + if ftype in ("char", "text", "html"): + return str(value) + if ftype == "selection": + return str(int(whole)) if whole is not None else str(value) + if ftype == "datetime": + return _to_datetime(value) + if ftype == "date": + dt = _to_datetime(value) + return dt.date() if dt else None + if ftype == "many2one": + return None if whole is None else int(whole) + if ftype in ("one2many", "many2many"): + ids = _to_ids(value) + return [Command.set(ids)] if ids else None + return None + + +def _candidates(root, records, segment): + hook = getattr(root, "_linking_candidates", None) + if hook is not None: + return hook(records, segment) + if "display_type" not in records._fields: + return records + # Skip section/note rows so positions count only real data lines. Models + # like account.move.line make display_type required and tag real lines as + # "product", so only these layout markers may be dropped, never every row. + return records.filtered( + lambda record: record.display_type + not in ("line_section", "line_subsection", "line_note") + ) + + +def _line_label(root, line, index): + # A glue module can override _linking_label to pin the preview to whatever + # best identifies its lines; otherwise fall back to display_name. + hook = getattr(root, "_linking_label", None) + if hook is not None: + label = hook(line) + if label: + return label + return line.display_name or root.env._("Line %s", index) + + +def _line_labels(root, lines): + return [ + {"position": index, "label": _line_label(root, line, index)} + for index, line in enumerate(lines, start=1) + ] + + +def _check_allowed(root): + hook = getattr(root, "_check_linking_allowed", None) + if hook is not None: + hook() + + +def _write(root, record, values): + hook = getattr(root, "_linking_write", None) + if hook is not None: + return hook(record, values) + return record.write(values) + + +def _create_line(root, parent, segment, values): + hook = getattr(root, "_create_linked_line", None) + if hook is not None: + return hook(parent, segment, values) + field = parent._fields[segment] + return parent.env[field.comodel_name].create( + {field.inverse_name: parent.id, **values} + ) + + +def _cast_values(records, leaves): + # Write gate: only stored, non-readonly fields pass, so an arbitrary chain + # from the client can never reach a computed or protected field. + values = {} + for name, raw in leaves.items(): + field = records._fields.get(name) + if not field or not field.store or field.readonly: + continue + value = _cast(field, raw) + if value is None: + continue + values[name] = value + return values + + +def _link_index(selector): + value = _to_float(selector) + if value is None or not value.is_integer() or value < 1: + return None + return int(value) + + +def _group_field_mappings(mappings): + groups = {} + for mapping in mappings or []: + chain = mapping.get("chain") + if not chain: + continue + *path, leaf = chain.split(".") + selectors = dict(mapping.get("selectors") or {}) + key = (tuple(path), tuple(selectors.get(seg) for seg in path)) + group = groups.setdefault( + key, {"path": path, "selectors": selectors, "leaves": {}} + ) + group["leaves"][leaf] = mapping.get("value") + return list(groups.values()) + + +def _resolve_link_target(root, group): + record = root + created = False + for depth, segment in enumerate(group["path"]): + field = record._fields.get(segment) + if field is None: + return None, False + related = record[segment] + if field.type in ("one2many", "many2many"): + index = _link_index(group["selectors"].get(segment)) + if index is None: + return None, False + candidates = _candidates(root, related, segment) + if index <= len(candidates): + record = candidates[index - 1] + elif depth == 0 and field.type == "one2many": + # Only the leading one2many grows a new line. + values = _cast_values(record.env[field.comodel_name], group["leaves"]) + if not values: + return None, False + record = _create_line(root, record, segment, values) + created = True + else: + return None, False + else: + if not related: + return None, False + record = related + if len(record) != 1: + return None, False + return record, created + + +def apply_field_mappings(root, mappings): + root.ensure_one() + _check_allowed(root) + updated = created = 0 + for group in _group_field_mappings(mappings): + record, was_created = _resolve_link_target(root, group) + if record is None: + continue + if was_created: + created += 1 + continue + values = _cast_values(record, group["leaves"]) + if values: + _write(root, record, values) + updated += 1 + return {"updated": updated, "created": created} + + +def linking_selectors(root, chain): + record = root + model = root + result = [] + for segment in chain.split(".")[:-1]: + field = model._fields.get(segment) + if field is None: + break + if field.type in ("one2many", "many2many"): + lines = record[segment] if record else model.browse() + lines = _candidates(root, lines, segment) + result.append({"segment": segment, "labels": _line_labels(root, lines)}) + record = lines[:1] + else: + record = record[segment] if record else model.browse() + model = root.env[field.comodel_name] + return result + + +def linking_context(root): + return { + "isLinkable": True, + "model": root._name, + "resId": root.id, + "recordName": root.display_name, + } + + +class SpreadsheetLinkable(models.AbstractModel): + """Optional helper for records that a spreadsheet writes into. + + The engine works on any record; a model inherits this mixin only for the + convenience API below. Its hooks (``_check_linking_allowed``, + ``_linking_write``, ``_linking_candidates``, ``_create_linked_line``, + ``_linking_label``) are consulted by duck-typing and need no mixin. + """ + + _name = "spreadsheet.linkable" + _description = "Spreadsheet Field Linking Mixin" + + def apply_field_mappings(self, mappings): + return apply_field_mappings(self, mappings) + + def _linking_context(self): + self.ensure_one() + return linking_context(self) + + def _linking_selectors(self, chain): + self.ensure_one() + return linking_selectors(self, chain) + + def _link_spreadsheet(self, spreadsheet): + self.ensure_one() + stale = spreadsheet.filtered( + lambda sheet: ( + sheet.link_res_model != self._name or sheet.link_res_id != self.id + ) + ) + if stale: + stale.sudo().write({"link_res_model": self._name, "link_res_id": self.id}) diff --git a/spreadsheet_field_linking/models/spreadsheet_spreadsheet.py b/spreadsheet_field_linking/models/spreadsheet_spreadsheet.py new file mode 100644 index 00000000..a720c8dc --- /dev/null +++ b/spreadsheet_field_linking/models/spreadsheet_spreadsheet.py @@ -0,0 +1,75 @@ +from odoo import api, fields, models +from odoo.fields import Domain + +from . import spreadsheet_linkable as engine + + +class SpreadsheetSpreadsheet(models.Model): + _inherit = "spreadsheet.spreadsheet" + + link_res_model = fields.Char(string="Linked Model", index=True, copy=False) + link_res_id = fields.Many2oneReference( + string="Linked Record", model_field="link_res_model", index=True, copy=False + ) + + def _get_link_target(self): + self.ensure_one() + model = self.link_res_model + if model and self.link_res_id and model in self.env: + record = self.env[model].browse(self.link_res_id).exists() + if record: + return record + return self.env["spreadsheet.linkable"] + + def _get_link_context(self): + self.ensure_one() + target = self._get_link_target() + if not target: + return { + "isLinkable": False, + "writable": False, + "model": False, + "resId": False, + "recordName": False, + } + return {**engine.linking_context(target), "writable": True} + + def get_linking_context(self): + self.ensure_one() + return {**self._get_link_context(), "spreadsheetId": self.id} + + def get_link_selectors(self, chain): + self.ensure_one() + context = self._get_link_context() + model = context.get("model") + if not model: + return [] + record = self.env[model].browse(context["resId"]) + return engine.linking_selectors(record, chain) + + def write_field_mappings(self, mappings): + self.ensure_one() + target = self._get_link_target() + if not target: + return {"updated": 0, "created": 0} + return engine.apply_field_mappings(target, mappings) + + def set_link_target(self, model=False, res_id=False): + self.ensure_one() + self.write({"link_res_model": model or False, "link_res_id": res_id or False}) + # Return the fresh context so the client can refresh in place. Reopening + # the spreadsheet action here would race with the live collaborative + # session for the same document ("Component is destroyed"). + return self.get_linking_context() + + @api.model + def get_linkable_models(self): + result = [] + domain = Domain("transient", "=", False) + for record in self.env["ir.model"].sudo().search(domain): + model = self.env.get(record.model) + if model is None or model._abstract or not model._auto: + continue + if model.has_access("write"): + result.append(record.model) + return result diff --git a/spreadsheet_field_linking/pyproject.toml b/spreadsheet_field_linking/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/spreadsheet_field_linking/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/spreadsheet_field_linking/readme/CONTRIBUTORS.md b/spreadsheet_field_linking/readme/CONTRIBUTORS.md new file mode 100644 index 00000000..2ea54631 --- /dev/null +++ b/spreadsheet_field_linking/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- [arielbarreiros96](https://www.github.com/arielbarreiros96) diff --git a/spreadsheet_field_linking/readme/DESCRIPTION.md b/spreadsheet_field_linking/readme/DESCRIPTION.md new file mode 100644 index 00000000..60ddf18b --- /dev/null +++ b/spreadsheet_field_linking/readme/DESCRIPTION.md @@ -0,0 +1,9 @@ +This module turns an Odoo spreadsheet into a tool that can be used to push data to a record. + +You link a spreadsheet to a record (a sale order, a project, a partner...), point some of its cells at the fields of that record, and then write the values you computed in the spreadsheet back onto the record in one click. + +A typical use is building a pricing or estimation sheet: you do the maths in the spreadsheet, and the results land on the right fields of the order without any copy-paste. Cells can target a field of the linked record, a field of a related record, or a field on the lines of the record. + +The links are stored inside the spreadsheet itself, so they are kept when the file is saved and reopened, and they follow their cells when rows are moved. + +This is a foundation module: it adds the linking tools to every Odoo spreadsheet but does not, by itself, tie any particular business document to a spreadsheet. Other modules build on it could offer ready-made spreadsheets for their own models. diff --git a/spreadsheet_field_linking/readme/USAGE.md b/spreadsheet_field_linking/readme/USAGE.md new file mode 100644 index 00000000..fc0e7243 --- /dev/null +++ b/spreadsheet_field_linking/readme/USAGE.md @@ -0,0 +1,62 @@ +Everything happens from inside the spreadsheet editor, through two menus: the **File** menu at the top left, and the **cell menu** you get by right-clicking a cell. + +## Link the spreadsheet to a record + +Open any spreadsheet and go to **File → Manage linked record**. This entry is always available. + +![](../static/description/file_manage_linked_record.png) + +A side panel opens on the right: + +- **Model** — pick the kind of record you want to link (Sale Order, Partner...). Only models you are allowed to edit are offered. +- **Record** — pick the specific record. +- **Link** — confirms the choice and links the spreadsheet to that record. +- **Unlink** — removes the link (shown only once a record is linked). + +The top of the panel always shows which record is currently linked, if any. + +![](../static/description/manage_linked_record_panel.png) + +## Link a cell to a field + +Once the spreadsheet is linked, you can point cells at the record's fields. Right-click the cell that holds the value you want to send and choose **Link to field** (or, on a cell that is already linked, **Edit field link**). The same entry is also available under **File → Link to field**. + +![](../static/description/cell_menu_link_to_field.png) + +A side panel opens for that cell: + +- **Field to link** — choose the field that should receive the cell's value. You can follow relations to reach a field of a related record, or a field on the record's lines. +- **Line (position)** — when the field lives on lines, tell the module which line to write to by its position (1 for the first line, 2 for the second...). + +A short **Saved** confirmation appears each time a change is stored. + +### Writing to lines, and creating new ones + +Many records hold a list of lines — a sale order, for example, has its order lines. You can write to those lines by their position, and you can also add brand-new ones: + +- **To update a line that already exists**, set its position (1, 2, 3...) to one that is already there. +- **To add a new line**, set a position just past the last one. Everything you mapped to that same position is gathered together into a single new line, so before you write, map every value the new line needs (description, quantity, price...) to that same position. + +Positions follow the order the lines appear on the record's form: position 1 is the first line, 2 the second, and so on. Because of this, a position points at a *slot*, not at one particular line — if you reorder, add, or remove lines, a position may end up aiming at a different line than before, so it is worth a quick check after rearranging. + +### A couple of limitations, and why they are there + +To avoid making a mess, the module only ever *creates* new lines that belong directly to the record you linked — the record's own list of lines, like the lines of the sale order itself. Two things it will deliberately not do, and the reason for each: + +- **It won't invent items that exist on their own.** Some fields don't hold lines that belong to the record; instead they point to things that live independently and can be shared by many records at once — think of the tags on a contact, or the product picked on a line. If one of those is already there, the module is glad to update a value on it. But it will not create a new one for you: doing that could quietly leave duplicates or half-finished entries scattered in other parts of Odoo that the rest of your data relies on. To stay safe, it only reuses what already exists in those spots. + +- **It won't build a line hidden deep inside another line.** The module only adds lines one step down, directly on the record you linked. To create something nested further in — a line tucked inside another line — it would first have to invent the in-between lines that aren't there yet, filling in details you never entered. Rather than guess, it simply leaves those untouched. + +So, in plain terms: the module creates a new line only where it can do so safely and without guessing — directly on the record you linked. Everywhere else it updates what is already there, but never creates. + +![](../static/description/field_link_panel.png) + +## Remove a link from a cell + +To unlink one or more cells, select them, right-click and choose **Remove field link**. This entry appears only when at least one cell in the selection is linked. + +## Write the values back to the record + +When the spreadsheet is linked to a record you can edit, a **Write to record** button appears in the top bar (it shows the record's name once one is linked, e.g. *Write to SO0042*). Click it to send every linked cell's current value to its field on the record at once. + +![](../static/description/write_to_record_button.png) diff --git a/spreadsheet_field_linking/static/description/cell_menu_link_to_field.png b/spreadsheet_field_linking/static/description/cell_menu_link_to_field.png new file mode 100644 index 00000000..1fbe28c7 Binary files /dev/null and b/spreadsheet_field_linking/static/description/cell_menu_link_to_field.png differ diff --git a/spreadsheet_field_linking/static/description/field_link_panel.png b/spreadsheet_field_linking/static/description/field_link_panel.png new file mode 100644 index 00000000..99ebed8b Binary files /dev/null and b/spreadsheet_field_linking/static/description/field_link_panel.png differ diff --git a/spreadsheet_field_linking/static/description/file_manage_linked_record.png b/spreadsheet_field_linking/static/description/file_manage_linked_record.png new file mode 100644 index 00000000..af934cc5 Binary files /dev/null and b/spreadsheet_field_linking/static/description/file_manage_linked_record.png differ diff --git a/spreadsheet_field_linking/static/description/index.html b/spreadsheet_field_linking/static/description/index.html new file mode 100644 index 00000000..2b2f8a8a --- /dev/null +++ b/spreadsheet_field_linking/static/description/index.html @@ -0,0 +1,562 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Spreadsheet Field Linking

+ +

Beta License: AGPL-3 OCA/spreadsheet Translate me on Weblate Try me on Runboat

+

This module turns an Odoo spreadsheet into a tool that can be used to +push data to a record.

+

You link a spreadsheet to a record (a sale order, a project, a +partner…), point some of its cells at the fields of that record, and +then write the values you computed in the spreadsheet back onto the +record in one click.

+

A typical use is building a pricing or estimation sheet: you do the +maths in the spreadsheet, and the results land on the right fields of +the order without any copy-paste. Cells can target a field of the linked +record, a field of a related record, or a field on the lines of the +record.

+

The links are stored inside the spreadsheet itself, so they are kept +when the file is saved and reopened, and they follow their cells when +rows are moved.

+

This is a foundation module: it adds the linking tools to every Odoo +spreadsheet but does not, by itself, tie any particular business +document to a spreadsheet. Other modules build on it could offer +ready-made spreadsheets for their own models.

+

Table of contents

+ +
+

Usage

+

Everything happens from inside the spreadsheet editor, through two +menus: the File menu at the top left, and the cell menu you get +by right-clicking a cell.

+ + + +
+

Write the values back to the record

+

When the spreadsheet is linked to a record you can edit, a Write to +record button appears in the top bar (it shows the record’s name once +one is linked, e.g. Write to SO0042). Click it to send every linked +cell’s current value to its field on the record at once.

+

image5

+
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • arielbarreiros96
  • +
+
+ +
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/spreadsheet project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+
+ + diff --git a/spreadsheet_field_linking/static/description/manage_linked_record_panel.png b/spreadsheet_field_linking/static/description/manage_linked_record_panel.png new file mode 100644 index 00000000..c82c7602 Binary files /dev/null and b/spreadsheet_field_linking/static/description/manage_linked_record_panel.png differ diff --git a/spreadsheet_field_linking/static/description/write_to_record_button.png b/spreadsheet_field_linking/static/description/write_to_record_button.png new file mode 100644 index 00000000..748ff937 Binary files /dev/null and b/spreadsheet_field_linking/static/description/write_to_record_button.png differ diff --git a/spreadsheet_field_linking/static/src/field_linking/collect_field_links.esm.js b/spreadsheet_field_linking/static/src/field_linking/collect_field_links.esm.js new file mode 100644 index 00000000..7a4fbdb8 --- /dev/null +++ b/spreadsheet_field_linking/static/src/field_linking/collect_field_links.esm.js @@ -0,0 +1,50 @@ +import {_t} from "@web/core/l10n/translation"; + +function targetKey({chain, selectors}) { + const parts = Object.keys(selectors || {}) + .sort() + .map((segment) => `${segment}=${selectors[segment]}`); + return `${chain}#${parts.join(",")}`; +} + +export function collectFieldLinks(model) { + const mappings = []; + const seen = {}; + const duplicates = new Set(); + for (const [position, fieldMapping] of model.getters.getAllFieldMappings()) { + if (!fieldMapping.chain) { + continue; + } + const cell = model.getters.getEvaluatedCell(position); + if (cell && cell.type === "error") { + continue; + } + const isEmpty = !cell || cell.type === "empty" || cell.value === ""; + const key = targetKey(fieldMapping); + const ref = model.getters.getRangeString( + model.getters.getRangeFromZone(position.sheetId, { + left: position.col, + right: position.col, + top: position.row, + bottom: position.row, + }), + position.sheetId + ); + if (seen[key]) { + duplicates.add(key); + } + seen[key] = ref; + mappings.push({ + key, + ref, + chain: fieldMapping.chain, + selectors: fieldMapping.selectors, + value: isEmpty ? null : cell.value, + }); + } + const errors = [...duplicates].map((key) => { + const refs = mappings.filter((m) => m.key === key).map((m) => m.ref); + return _t("Several cells target the same field: %s", refs.join(", ")); + }); + return {mappings, errors}; +} diff --git a/spreadsheet_field_linking/static/src/field_linking/field_link_core_plugin.esm.js b/spreadsheet_field_linking/static/src/field_linking/field_link_core_plugin.esm.js new file mode 100644 index 00000000..ddd12896 --- /dev/null +++ b/spreadsheet_field_linking/static/src/field_linking/field_link_core_plugin.esm.js @@ -0,0 +1,193 @@ +import {CommandResult, helpers} from "@odoo/o-spreadsheet"; +import {OdooCorePlugin} from "@spreadsheet/plugins"; + +const {positionToZone, toCartesian, toXC} = helpers; + +function sameSelectors(a = {}, b = {}) { + const keys = new Set([...Object.keys(a), ...Object.keys(b)]); + for (const key of keys) { + if (String(a[key]) !== String(b[key])) { + return false; + } + } + return true; +} + +export class FieldLinkCorePlugin extends OdooCorePlugin { + static getters = [ + "getAllFieldMappings", + "getFieldMapping", + "getFieldMappingsInZone", + "getFieldLinkContext", + ]; + + fieldMappings = {}; + + constructor(config) { + super(config); + const custom = config.custom || {}; + this.linkContext = custom.linkContext || { + isLinkable: false, + writable: false, + model: false, + resId: false, + recordName: false, + spreadsheetId: custom.linkResId || false, + }; + if (!custom.linkContext && custom.linkResId && custom.orm) { + custom.orm + .call("spreadsheet.spreadsheet", "get_linking_context", [ + [custom.linkResId], + ]) + .then((context) => Object.assign(this.linkContext, context)) + .catch(() => { + // Best effort: keep the default not-linked context. + }); + } + } + + getFieldLinkContext() { + return this.linkContext; + } + + allowDispatch(cmd) { + switch (cmd.type) { + case "MAP_FIELD": { + const current = this.getFieldMapping(cmd); + if ( + current && + current.chain === cmd.chain && + sameSelectors(current.selectors, cmd.selectors) + ) { + return CommandResult.NoChanges; + } + break; + } + case "UNMAP_FIELDS": + if (this.getFieldMappingsInZone(cmd.sheetId, cmd.zone).length === 0) { + return CommandResult.NoChanges; + } + break; + } + return CommandResult.Success; + } + + handle(cmd) { + switch (cmd.type) { + case "MAP_FIELD": { + const selectors = {}; + for (const [segment, position] of Object.entries(cmd.selectors || {})) { + selectors[segment] = Math.max(1, position); + } + this.history.update("fieldMappings", cmd.sheetId, cmd.col, cmd.row, { + chain: cmd.chain, + selectors, + }); + break; + } + case "UNMAP_FIELDS": + for (let col = cmd.zone.left; col <= cmd.zone.right; col++) { + for (let row = cmd.zone.top; row <= cmd.zone.bottom; row++) { + this.history.update( + "fieldMappings", + cmd.sheetId, + col, + row, + undefined + ); + } + } + break; + } + } + + adaptRanges({applyChange}) { + const removed = []; + const moved = []; + for (const [position, fieldMapping] of this.getAllFieldMappings()) { + const change = applyChange( + this.getters.getRangeFromZone( + position.sheetId, + positionToZone(position) + ) + ); + if (change.changeType === "REMOVE") { + removed.push(position); + } else if (change.changeType !== "NONE") { + removed.push(position); + moved.push([ + { + sheetId: position.sheetId, + col: change.range.zone.left, + row: change.range.zone.top, + }, + fieldMapping, + ]); + } + } + for (const {sheetId, col, row} of removed) { + this.history.update("fieldMappings", sheetId, col, row, undefined); + } + for (const [{sheetId, col, row}, fieldMapping] of moved) { + this.history.update("fieldMappings", sheetId, col, row, fieldMapping); + } + } + + getFieldMapping({sheetId, col, row}) { + return this.fieldMappings[sheetId]?.[col]?.[row]; + } + + getAllFieldMappings() { + const result = new Map(); + for (const sheetId in this.fieldMappings) { + for (const col in this.fieldMappings[sheetId]) { + for (const row in this.fieldMappings[sheetId][col]) { + const fieldMapping = this.fieldMappings[sheetId][col][row]; + if (fieldMapping) { + result.set( + {sheetId, col: Number(col), row: Number(row)}, + fieldMapping + ); + } + } + } + } + return result; + } + + getFieldMappingsInZone(sheetId, zone) { + const result = []; + for (let col = zone.left; col <= zone.right; col++) { + for (let row = zone.top; row <= zone.bottom; row++) { + const fieldMapping = this.getFieldMapping({sheetId, col, row}); + if (fieldMapping) { + result.push(fieldMapping); + } + } + } + return result; + } + + export(data) { + for (const [position, fieldMapping] of this.getAllFieldMappings()) { + const sheet = data.sheets.find((s) => s.id === position.sheetId); + if (!sheet) { + continue; + } + sheet.fieldMappings ??= {}; + sheet.fieldMappings[toXC(position.col, position.row)] = fieldMapping; + } + } + + import(data) { + for (const sheet of data.sheets) { + if (!sheet.fieldMappings) { + continue; + } + for (const [xc, fieldMapping] of Object.entries(sheet.fieldMappings)) { + const {col, row} = toCartesian(xc); + this.history.update("fieldMappings", sheet.id, col, row, fieldMapping); + } + } + } +} diff --git a/spreadsheet_field_linking/static/src/field_linking/field_link_registration.esm.js b/spreadsheet_field_linking/static/src/field_linking/field_link_registration.esm.js new file mode 100644 index 00000000..8597fdbb --- /dev/null +++ b/spreadsheet_field_linking/static/src/field_linking/field_link_registration.esm.js @@ -0,0 +1,103 @@ +import {coreTypes, registries} from "@odoo/o-spreadsheet"; +import {FieldLinkCorePlugin} from "./field_link_core_plugin.esm"; +import {FieldLinkSidePanel} from "./field_link_side_panel.esm"; +import {ManageLinkedRecordSidePanel} from "./manage_linked_record_side_panel.esm"; +import {_t} from "@web/core/l10n/translation"; + +const { + corePluginRegistry, + sidePanelRegistry, + cellMenuRegistry, + topbarMenuRegistry, + inverseCommandRegistry, +} = registries; + +const identity = (cmd) => cmd; +const isLinkable = (env) => + Boolean(env.model.getters.getFieldLinkContext?.().isLinkable); + +const linkActiveCell = (env) => { + const position = env.model.getters.getActivePosition(); + if (!env.model.getters.getFieldMapping(position)) { + env.model.dispatch("MAP_FIELD", { + sheetId: position.sheetId, + col: position.col, + row: position.row, + chain: "", + selectors: {}, + }); + } + env.openSidePanel("FieldLinkPanel"); +}; + +coreTypes.add("MAP_FIELD").add("UNMAP_FIELDS"); +corePluginRegistry.add("field_link", FieldLinkCorePlugin); +inverseCommandRegistry.add("MAP_FIELD", identity); +inverseCommandRegistry.add("UNMAP_FIELDS", identity); + +sidePanelRegistry.add("ManageLinkedRecord", { + title: _t("Manage linked record"), + Body: ManageLinkedRecordSidePanel, +}); + +sidePanelRegistry.add("FieldLinkPanel", { + title: _t("Link to field"), + Body: FieldLinkSidePanel, + computeState(getters, initialProps) { + const position = getters.getActivePosition(); + return { + isOpen: Boolean(getters.getFieldMapping?.(position)), + props: {...initialProps, position}, + key: `${position.sheetId}-${position.col}-${position.row}`, + }; + }, +}); + +cellMenuRegistry.add("field_link", { + name: (env) => + env.model.getters.getFieldMapping?.(env.model.getters.getActivePosition()) + ? _t("Edit field link") + : _t("Link to field"), + icon: "o-spreadsheet-Icon.REFRESH", + sequence: 200, + isVisible: (env) => isLinkable(env) && !env.isSmall, + execute: linkActiveCell, +}); + +cellMenuRegistry.add("field_link_delete", { + name: _t("Remove field link"), + icon: "o-spreadsheet-Icon.TRASH", + sequence: 201, + isVisible: (env) => { + if (!isLinkable(env)) { + return false; + } + const sheetId = env.model.getters.getActiveSheetId(); + return env.model.getters + .getSelectedZones() + .some( + (zone) => env.model.getters.getFieldMappingsInZone(sheetId, zone).length + ); + }, + execute: (env) => { + const sheetId = env.model.getters.getActiveSheetId(); + for (const zone of env.model.getters.getSelectedZones()) { + env.model.dispatch("UNMAP_FIELDS", {sheetId, zone}); + } + }, +}); + +topbarMenuRegistry.addChild("manage_linked_record", ["file"], { + name: _t("Manage linked record"), + icon: "o-spreadsheet-Icon.INSERT_LINK", + sequence: 90, + execute: (env) => env.openSidePanel("ManageLinkedRecord"), +}); + +topbarMenuRegistry.addChild("field_link", ["file"], { + name: _t("Link to field"), + icon: "o-spreadsheet-Icon.REFRESH", + sequence: 100, + isVisible: isLinkable, + execute: linkActiveCell, +}); diff --git a/spreadsheet_field_linking/static/src/field_linking/field_link_renderer_patch.esm.js b/spreadsheet_field_linking/static/src/field_linking/field_link_renderer_patch.esm.js new file mode 100644 index 00000000..91af409b --- /dev/null +++ b/spreadsheet_field_linking/static/src/field_linking/field_link_renderer_patch.esm.js @@ -0,0 +1,15 @@ +import {SpreadsheetRenderer} from "@spreadsheet_oca/spreadsheet/bundle/spreadsheet_renderer.esm"; +import {patch} from "@web/core/utils/patch"; + +patch(SpreadsheetRenderer.prototype, { + setup() { + super.setup(); + if (this.env.linkModelHolder) { + this.env.linkModelHolder.model = this.spreadsheet_model; + } + }, + + getExtraModelCustom() { + return {...super.getExtraModelCustom(), linkResId: this.props.res_id}; + }, +}); diff --git a/spreadsheet_field_linking/static/src/field_linking/field_link_side_panel.esm.js b/spreadsheet_field_linking/static/src/field_linking/field_link_side_panel.esm.js new file mode 100644 index 00000000..c89498cb --- /dev/null +++ b/spreadsheet_field_linking/static/src/field_linking/field_link_side_panel.esm.js @@ -0,0 +1,99 @@ +import {Component, onWillStart, useState} from "@odoo/owl"; +import {components, helpers} from "@odoo/o-spreadsheet"; +import {ModelFieldSelector} from "@web/core/model_field_selector/model_field_selector"; +import {useService} from "@web/core/utils/hooks"; + +const {Section} = components; +const {positionToZone} = helpers; + +export class FieldLinkSidePanel extends Component { + static template = "spreadsheet_field_linking.FieldLinkPanel"; + static components = {ModelFieldSelector, Section}; + static props = { + onCloseSidePanel: Function, + position: Object, + }; + + setup() { + this.orm = useService("orm"); + this.state = useState({saved: false, selectors: []}); + onWillStart(async () => { + this.state.selectors = await this._fetchSelectors(this.fieldMapping.chain); + }); + } + + get context() { + return this.env.model.getters.getFieldLinkContext(); + } + + get fieldMapping() { + return this.env.model.getters.getFieldMapping(this.props.position); + } + + get cellReference() { + const {sheetId} = this.props.position; + const range = this.env.model.getters.getRangeFromZone( + sheetId, + positionToZone(this.props.position) + ); + return this.env.model.getters.getRangeString(range, sheetId); + } + + filterField(field) { + return field.store && !field.readonly; + } + + lineLabel(segment) { + const entry = this.state.selectors.find((sel) => sel.segment === segment); + const position = this.fieldMapping.selectors[segment]; + return entry?.labels.find((line) => line.position === position)?.label || ""; + } + + async _fetchSelectors(chain) { + if (!chain) { + return []; + } + return this.orm.call("spreadsheet.spreadsheet", "get_link_selectors", [ + [this.context.spreadsheetId], + chain, + ]); + } + + async updateChain(chain) { + const selectors = await this._fetchSelectors(chain); + const values = {}; + selectors.forEach((entry, index) => { + // Default the leading hop to the cell's own row, deeper hops to 1. + values[entry.segment] = + this.fieldMapping.selectors[entry.segment] || + (index === 0 ? this.props.position.row + 1 : 1); + }); + this.state.selectors = selectors; + this._update({chain, selectors: values}); + } + + updatePosition(segment, ev) { + const position = parseInt(ev.target.value, 10); + if (position >= 1) { + this._update({ + selectors: {...this.fieldMapping.selectors, [segment]: position}, + }); + } + } + + _update(partial) { + const {sheetId, col, row} = this.props.position; + const result = this.env.model.dispatch("MAP_FIELD", { + sheetId, + col, + row, + chain: this.fieldMapping.chain, + selectors: this.fieldMapping.selectors, + ...partial, + }); + if (result.isSuccessful) { + this.state.saved = true; + setTimeout(() => (this.state.saved = false), 1500); + } + } +} diff --git a/spreadsheet_field_linking/static/src/field_linking/field_link_side_panel.xml b/spreadsheet_field_linking/static/src/field_linking/field_link_side_panel.xml new file mode 100644 index 00000000..ff5d93e8 --- /dev/null +++ b/spreadsheet_field_linking/static/src/field_linking/field_link_side_panel.xml @@ -0,0 +1,50 @@ + + + + + + diff --git a/spreadsheet_field_linking/static/src/field_linking/manage_linked_record_side_panel.esm.js b/spreadsheet_field_linking/static/src/field_linking/manage_linked_record_side_panel.esm.js new file mode 100644 index 00000000..3f277fd7 --- /dev/null +++ b/spreadsheet_field_linking/static/src/field_linking/manage_linked_record_side_panel.esm.js @@ -0,0 +1,99 @@ +import {Component, onWillStart, useState} from "@odoo/owl"; +import {ModelSelector} from "@web/core/model_selector/model_selector"; +import {RecordSelector} from "@web/core/record_selectors/record_selector"; +import {components} from "@odoo/o-spreadsheet"; +import {useService} from "@web/core/utils/hooks"; + +const {Section} = components; + +export class ManageLinkedRecordSidePanel extends Component { + static template = "spreadsheet_field_linking.ManageLinkedRecordPanel"; + static components = {ModelSelector, RecordSelector, Section}; + static props = {onCloseSidePanel: Function}; + + setup() { + this.orm = useService("orm"); + this.models = []; + this.linked = useState(this._snapshot()); + this.state = useState({ + model: this.linked.model || "", + modelLabel: "", + resId: this.linked.resId || false, + }); + onWillStart(async () => { + this.models = await this.orm.call( + "spreadsheet.spreadsheet", + "get_linkable_models", + [] + ); + await this._loadModelLabel(); + }); + } + + _snapshot() { + const context = this.env.model.getters.getFieldLinkContext(); + return { + isLinkable: context.isLinkable, + model: context.model, + resId: context.resId, + recordName: context.recordName, + spreadsheetId: context.spreadsheetId, + }; + } + + async _loadModelLabel() { + if (!this.state.model) { + this.state.modelLabel = ""; + return; + } + const [info] = await this.orm.call("ir.model", "display_name_for", [ + [this.state.model], + ]); + this.state.modelLabel = info?.display_name || this.state.model; + } + + get context() { + return this.linked; + } + + get canApply() { + return Boolean(this.state.model && this.state.resId); + } + + async onModelSelected(model) { + this.state.model = model.technical; + this.state.modelLabel = model.label; + this.state.resId = false; + } + + onRecordSelected(resId) { + this.state.resId = resId || false; + } + + async _setTarget(model, resId) { + const context = await this.orm.call( + "spreadsheet.spreadsheet", + "set_link_target", + [[this.linked.spreadsheetId], model, resId] + ); + Object.assign(this.env.model.getters.getFieldLinkContext(), context); + if (this.env.linkState) { + this.env.linkState.writable = context.writable; + this.env.linkState.recordName = context.recordName || ""; + } + Object.assign(this.linked, this._snapshot()); + this.state.model = this.linked.model || ""; + this.state.resId = this.linked.resId || false; + await this._loadModelLabel(); + } + + async onApply() { + if (this.canApply) { + await this._setTarget(this.state.model, this.state.resId); + } + } + + async onUnlink() { + await this._setTarget(false, false); + } +} diff --git a/spreadsheet_field_linking/static/src/field_linking/manage_linked_record_side_panel.xml b/spreadsheet_field_linking/static/src/field_linking/manage_linked_record_side_panel.xml new file mode 100644 index 00000000..e09913b2 --- /dev/null +++ b/spreadsheet_field_linking/static/src/field_linking/manage_linked_record_side_panel.xml @@ -0,0 +1,45 @@ + + + +
+
+ + + + () + + Not linked to any record. +
+
+ +
+
+ +
+
+ + +
+
+
+
diff --git a/spreadsheet_field_linking/static/src/field_linking/spreadsheet_action.xml b/spreadsheet_field_linking/static/src/field_linking/spreadsheet_action.xml new file mode 100644 index 00000000..5e503b10 --- /dev/null +++ b/spreadsheet_field_linking/static/src/field_linking/spreadsheet_action.xml @@ -0,0 +1,17 @@ + + + + + + + + + + diff --git a/spreadsheet_field_linking/static/src/field_linking/spreadsheet_action_patch.esm.js b/spreadsheet_field_linking/static/src/field_linking/spreadsheet_action_patch.esm.js new file mode 100644 index 00000000..55e01f5b --- /dev/null +++ b/spreadsheet_field_linking/static/src/field_linking/spreadsheet_action_patch.esm.js @@ -0,0 +1,71 @@ +import {onWillStart, useState, useSubEnv} from "@odoo/owl"; +import {ActionSpreadsheetOca} from "@spreadsheet_oca/spreadsheet/bundle/spreadsheet_action.esm"; +import {WarningDialog} from "@web/core/errors/error_dialogs"; +import {_t} from "@web/core/l10n/translation"; +import {collectFieldLinks} from "./collect_field_links.esm"; +import {patch} from "@web/core/utils/patch"; +import {useService} from "@web/core/utils/hooks"; + +patch(ActionSpreadsheetOca.prototype, { + setup() { + super.setup(); + this.action = useService("action"); + this.dialog = useService("dialog"); + this.linkModelHolder = {model: null}; + this.linkState = useState({writable: false, recordName: ""}); + useSubEnv({linkModelHolder: this.linkModelHolder, linkState: this.linkState}); + onWillStart(async () => { + const context = await this.orm.call( + "spreadsheet.spreadsheet", + "get_linking_context", + [[this.spreadsheetId]] + ); + this.linkState.writable = context.writable; + this.linkState.recordName = context.recordName || ""; + }); + }, + + get writeToRecordLabel() { + return this.linkState.recordName + ? _t("Write to %s", this.linkState.recordName) + : _t("Write to record"); + }, + + async onWriteToRecord() { + const model = this.linkModelHolder.model; + if (!model) { + return; + } + const {mappings, errors} = collectFieldLinks(model); + if (errors.length) { + this.dialog.add(WarningDialog, { + title: _t("Unable to write to the record"), + message: errors.join("\n\n"), + }); + return; + } + const result = await this.orm.call( + "spreadsheet.spreadsheet", + "write_field_mappings", + [ + [this.spreadsheetId], + mappings.map(({chain, selectors, value}) => ({ + chain, + selectors, + value, + })), + ] + ); + const parts = []; + if (result.updated) { + parts.push(_t("%s line(s) updated", result.updated)); + } + if (result.created) { + parts.push(_t("%s line(s) created", result.created)); + } + this.notification.add(parts.join(", ") || _t("No values to write."), { + type: parts.length ? "success" : "info", + }); + this.action.doAction({type: "ir.actions.act_window_close"}); + }, +}); diff --git a/spreadsheet_field_linking/static/tests/field_link_core_plugin.test.js b/spreadsheet_field_linking/static/tests/field_link_core_plugin.test.js new file mode 100644 index 00000000..5a310ac5 --- /dev/null +++ b/spreadsheet_field_linking/static/tests/field_link_core_plugin.test.js @@ -0,0 +1,188 @@ +// Side-effect import: registers the plugin and command types. +import "@spreadsheet_field_linking/field_linking/field_link_registration.esm"; +import * as spreadsheet from "@odoo/o-spreadsheet"; +import {describe, expect, test} from "@odoo/hoot"; +import {addRows, setCellContent} from "@spreadsheet/../tests/helpers/commands"; +import {collectFieldLinks} from "@spreadsheet_field_linking/field_linking/collect_field_links.esm"; +import {createModelWithDataSource} from "@spreadsheet/../tests/helpers/model"; +import {defineSpreadsheetModels} from "@spreadsheet/../tests/helpers/data"; + +const {toCartesian, toZone} = spreadsheet.helpers; + +defineSpreadsheetModels(); +describe.current.tags("headless"); + +async function makeModel() { + const {model} = await createModelWithDataSource({ + modelConfig: { + custom: { + linkContext: { + isLinkable: true, + writable: true, + model: "res.partner", + resId: 1, + recordName: "Partner", + }, + }, + }, + }); + return model; +} + +function mapField(model, xc, chain, selectors) { + const {col, row} = toCartesian(xc); + return model.dispatch("MAP_FIELD", { + sheetId: model.getters.getActiveSheetId(), + col, + row, + chain, + selectors, + }); +} + +function getMapping(model, xc) { + const {col, row} = toCartesian(xc); + return model.getters.getFieldMapping({ + sheetId: model.getters.getActiveSheetId(), + col, + row, + }); +} + +function unmap(model, xc) { + return model.dispatch("UNMAP_FIELDS", { + sheetId: model.getters.getActiveSheetId(), + zone: toZone(xc), + }); +} + +describe("field link core plugin", () => { + test("stores a link on a cell", async () => { + const model = await makeModel(); + const result = mapField(model, "B2", "line_ids.price", {line_ids: 2}); + expect(result.isSuccessful).toBe(true); + expect(getMapping(model, "B2")).toEqual({ + chain: "line_ids.price", + selectors: {line_ids: 2}, + }); + }); + + test("clamps a selector position to at least 1", async () => { + const model = await makeModel(); + mapField(model, "A1", "line_ids.price", {line_ids: 0}); + expect(getMapping(model, "A1").selectors.line_ids).toBe(1); + }); + + test("re-linking a cell to the same target is a no-op", async () => { + const model = await makeModel(); + mapField(model, "A1", "line_ids.price", {line_ids: 1}); + const result = mapField(model, "A1", "line_ids.price", {line_ids: 1}); + expect(result.isSuccessful).toBe(false); + }); + + test("unmapping clears the links in a zone", async () => { + const model = await makeModel(); + mapField(model, "A1", "line_ids.price", {line_ids: 1}); + const result = unmap(model, "A1"); + expect(result.isSuccessful).toBe(true); + expect(getMapping(model, "A1")).toBe(undefined); + }); + + test("unmapping an empty zone is a no-op", async () => { + const model = await makeModel(); + expect(unmap(model, "A1").isSuccessful).toBe(false); + }); + + test("returns every link", async () => { + const model = await makeModel(); + mapField(model, "A1", "line_ids.price", {line_ids: 1}); + mapField(model, "B2", "line_ids.qty", {line_ids: 2}); + expect(model.getters.getAllFieldMappings().size).toBe(2); + }); + + test("links survive an export/import round-trip", async () => { + const model = await makeModel(); + mapField(model, "A1", "line_ids.qty", {line_ids: 3}); + const {model: reloaded} = await createModelWithDataSource({ + spreadsheetData: model.exportData(), + }); + expect(getMapping(reloaded, "A1")).toEqual({ + chain: "line_ids.qty", + selectors: {line_ids: 3}, + }); + }); + + test("a link follows its cell when rows are inserted above", async () => { + const model = await makeModel(); + mapField(model, "A3", "line_ids.price", {line_ids: 1}); + addRows(model, "before", 0, 1); + expect(getMapping(model, "A3")).toBe(undefined); + expect(getMapping(model, "A4")).toEqual({ + chain: "line_ids.price", + selectors: {line_ids: 1}, + }); + }); + + test("a link is dropped when its row is deleted", async () => { + const model = await makeModel(); + mapField(model, "A3", "line_ids.price", {line_ids: 1}); + model.dispatch("REMOVE_COLUMNS_ROWS", { + sheetId: model.getters.getActiveSheetId(), + dimension: "ROW", + elements: [2], + }); + expect(model.getters.getAllFieldMappings().size).toBe(0); + }); + + test("exposes the seeded link context", async () => { + const model = await makeModel(); + expect(model.getters.getFieldLinkContext().model).toBe("res.partner"); + expect(model.getters.getFieldLinkContext().isLinkable).toBe(true); + }); +}); + +describe("collect field links", () => { + test("reads a mapped cell's computed value", async () => { + const model = await makeModel(); + setCellContent(model, "A1", "42"); + mapField(model, "A1", "note", {}); + const {mappings, errors} = collectFieldLinks(model); + expect(errors).toEqual([]); + expect(mappings).toHaveLength(1); + expect(mappings[0].chain).toBe("note"); + expect(mappings[0].value).toBe(42); + }); + + test("sends null for an empty cell", async () => { + const model = await makeModel(); + mapField(model, "A1", "note", {}); + expect(collectFieldLinks(model).mappings[0].value).toBe(null); + }); + + test("skips a cell in error", async () => { + const model = await makeModel(); + setCellContent(model, "A1", "=1/0"); + mapField(model, "A1", "note", {}); + expect(collectFieldLinks(model).mappings).toHaveLength(0); + }); + + test("skips a cell without a chain", async () => { + const model = await makeModel(); + setCellContent(model, "A1", "42"); + mapField(model, "A1", "", {}); + expect(collectFieldLinks(model).mappings).toHaveLength(0); + }); + + test("flags two cells targeting the same field", async () => { + const model = await makeModel(); + setCellContent(model, "A1", "1"); + setCellContent(model, "B1", "2"); + mapField(model, "A1", "note", {}); + mapField(model, "B1", "note", {}); + const {mappings, errors} = collectFieldLinks(model); + expect(mappings).toHaveLength(2); + expect(errors).toHaveLength(1); + expect(errors[0]).toInclude("A1"); + expect(errors[0]).toInclude("B1"); + }); +}); diff --git a/spreadsheet_field_linking/tests/__init__.py b/spreadsheet_field_linking/tests/__init__.py new file mode 100644 index 00000000..58e052ba --- /dev/null +++ b/spreadsheet_field_linking/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_cast +from . import test_apply_field_mappings diff --git a/spreadsheet_field_linking/tests/test_apply_field_mappings.py b/spreadsheet_field_linking/tests/test_apply_field_mappings.py new file mode 100644 index 00000000..4da36955 --- /dev/null +++ b/spreadsheet_field_linking/tests/test_apply_field_mappings.py @@ -0,0 +1,398 @@ +# Copyright 2026 arielbarreiros96 +# License AGPL-3.0 or later (https://www.gnu.org/licenses/AGPL-3.0). + +from odoo import Command +from odoo.exceptions import UserError +from odoo.orm.model_classes import add_to_registry +from odoo.tests.common import TransactionCase + + +class TestApplyFieldMappings(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + from .test_models import ( + FieldLinkingHookedLine, + FieldLinkingHookedOrder, + FieldLinkingTestLine, + FieldLinkingTestOrder, + ) + + registered = ( + FieldLinkingTestOrder, + FieldLinkingTestLine, + FieldLinkingHookedOrder, + FieldLinkingHookedLine, + ) + names = [model._name for model in registered] + for model in registered: + add_to_registry(cls.registry, model) + cls.registry._setup_models__(cls.env.cr, names) + cls.registry.init_models(cls.env.cr, names, {"models_to_check": True}) + for name in names: + cls.addClassCleanup(cls.registry.__delitem__, name) + + cls.partner = cls.env["res.partner"].create({"name": "Partner"}) + cls.other_partner = cls.env["res.partner"].create({"name": "Other"}) + cls.Order = cls.env["field.linking.test.order"] + cls.order = cls.Order.create( + { + "name": "Order", + "partner_id": cls.partner.id, + "line_ids": [ + Command.create({"label": "First", "qty": 1, "price": 100.0}), + Command.create({"label": "S", "display_type": "line_section"}), + Command.create({"label": "Second", "qty": 2, "price": 50.0}), + ], + } + ) + cls.lines = cls.order.line_ids.filtered(lambda line: not line.display_type) + cls.hooked = cls.env["field.linking.hooked.order"].create( + {"name": "H", "line_ids": [Command.create({"label": "L1", "qty": 1})]} + ) + + def _line(self, field, position, value): + return { + "chain": f"line_ids.{field}", + "selectors": {"line_ids": position}, + "value": value, + } + + def test_write_root_field(self): + result = self.order.apply_field_mappings([{"chain": "note", "value": "hello"}]) + self.assertEqual(result, {"updated": 1, "created": 0}) + self.assertEqual(self.order.note, "hello") + + def test_readonly_field_is_skipped(self): + result = self.order.apply_field_mappings([{"chain": "total", "value": 5.0}]) + self.assertEqual(result, {"updated": 0, "created": 0}) + self.assertEqual(self.order.total, 150.0) + + def test_unknown_field_is_skipped(self): + result = self.order.apply_field_mappings([{"chain": "nope", "value": 1}]) + self.assertEqual(result, {"updated": 0, "created": 0}) + + def test_empty_cell_clears_field(self): + self.order.apply_field_mappings([{"chain": "note", "value": ""}]) + self.assertFalse(self.order.note) + + def test_write_line_by_position(self): + result = self.order.apply_field_mappings([self._line("price", 1, 250.0)]) + self.assertEqual(result, {"updated": 1, "created": 0}) + self.assertEqual(self.lines[0].price, 250.0) + + def test_position_skips_section_lines(self): + self.order.apply_field_mappings([self._line("qty", 2, 9)]) + self.assertEqual(self.lines[1].qty, 9) + self.assertEqual(self.lines[0].qty, 1) + + def test_tagged_real_lines_are_counted(self): + # Reproduces account.move.line semantics: display_type is required and + # set on real lines ("product"), not only on sections. Only the layout + # markers (section/note) must be skipped, never every tagged line. + order = self.Order.create( + { + "name": "Entry", + "line_ids": [ + Command.create({"label": "Sec", "display_type": "line_section"}), + Command.create( + {"label": "Real", "qty": 1, "display_type": "product"} + ), + ], + } + ) + selectors = order._linking_selectors("line_ids.qty") + positions = [line["position"] for line in selectors[0]["labels"]] + self.assertEqual(positions, [1]) + result = order.apply_field_mappings( + [{"chain": "line_ids.qty", "selectors": {"line_ids": 1}, "value": 7.0}] + ) + self.assertEqual(result, {"updated": 1, "created": 0}) + real = order.line_ids.filtered(lambda line: line.display_type == "product") + self.assertEqual(real.qty, 7.0) + + def test_grouped_leaves_write_one_line(self): + result = self.order.apply_field_mappings( + [self._line("price", 1, 250.0), self._line("qty", 1, 3)] + ) + self.assertEqual(result, {"updated": 1, "created": 0}) + self.assertEqual(self.lines[0].price, 250.0) + self.assertEqual(self.lines[0].qty, 3) + + def test_creates_line_beyond_count(self): + result = self.order.apply_field_mappings( + [ + self._line("partner_id", 3, self.other_partner.id), + self._line("qty", 3, 4), + self._line("price", 3, 75.0), + ] + ) + self.assertEqual(result, {"updated": 0, "created": 1}) + lines = self.order.line_ids.filtered(lambda line: not line.display_type) + self.assertEqual(len(lines), 3) + self.assertEqual(lines[2].partner_id, self.other_partner) + self.assertEqual(lines[2].qty, 4) + self.assertEqual(lines[2].price, 75.0) + + def test_new_line_without_writable_values_is_skipped(self): + result = self.order.apply_field_mappings([self._line("nope", 3, 4)]) + self.assertEqual(result, {"updated": 0, "created": 0}) + + def test_invalid_selector_is_skipped(self): + result = self.order.apply_field_mappings( + [{"chain": "line_ids.price", "selectors": {"line_ids": 0}, "value": 9.0}] + ) + self.assertEqual(result, {"updated": 0, "created": 0}) + + def test_write_through_many2one(self): + result = self.order.apply_field_mappings( + [{"chain": "partner_id.ref", "value": "REF-42"}] + ) + self.assertEqual(result, {"updated": 1, "created": 0}) + self.assertEqual(self.partner.ref, "REF-42") + + def test_empty_many2one_is_skipped(self): + self.order.partner_id = False + result = self.order.apply_field_mappings( + [{"chain": "partner_id.ref", "value": "x"}] + ) + self.assertEqual(result, {"updated": 0, "created": 0}) + + def test_state_guard_raises(self): + self.order.state = "done" + with self.assertRaises(UserError): + self.order.apply_field_mappings([{"chain": "note", "value": "x"}]) + + def test_linking_context(self): + context = self.order._linking_context() + self.assertTrue(context["isLinkable"]) + self.assertEqual(context["model"], "field.linking.test.order") + self.assertEqual(context["resId"], self.order.id) + self.assertEqual(context["recordName"], self.order.display_name) + + def test_linking_selectors_for_line_chain(self): + selectors = self.order._linking_selectors("line_ids.price") + self.assertEqual(len(selectors), 1) + self.assertEqual(selectors[0]["segment"], "line_ids") + positions = [line["position"] for line in selectors[0]["labels"]] + self.assertEqual(positions, [1, 2]) + + def test_linking_selectors_for_root_field(self): + self.assertEqual(self.order._linking_selectors("note"), []) + + def test_link_spreadsheet_sets_reference(self): + sheet = self.env["spreadsheet.spreadsheet"].create({"name": "S"}) + self.order._link_spreadsheet(sheet) + self.assertEqual(sheet.link_res_model, self.order._name) + self.assertEqual(sheet.link_res_id, self.order.id) + + def test_duplicate_drops_the_link(self): + sheet = self.env["spreadsheet.spreadsheet"].create({"name": "S"}) + self.order._link_spreadsheet(sheet) + copy = sheet.copy() + self.assertFalse(copy.link_res_model) + self.assertFalse(copy.link_res_id) + context = copy.get_linking_context() + self.assertFalse(context["isLinkable"]) + + def test_context_resolves_through_reference(self): + sheet = self.env["spreadsheet.spreadsheet"].create({"name": "S"}) + self.order._link_spreadsheet(sheet) + context = sheet.get_linking_context() + self.assertTrue(context["writable"]) + self.assertEqual(context["resId"], self.order.id) + + def test_get_link_selectors_through_reference(self): + sheet = self.env["spreadsheet.spreadsheet"].create({"name": "S"}) + self.order._link_spreadsheet(sheet) + selectors = sheet.get_link_selectors("line_ids.price") + positions = [line["position"] for line in selectors[0]["labels"]] + self.assertEqual(positions, [1, 2]) + + def test_write_resolves_through_reference(self): + sheet = self.env["spreadsheet.spreadsheet"].create({"name": "S"}) + self.order._link_spreadsheet(sheet) + result = sheet.write_field_mappings([{"chain": "note", "value": "hi"}]) + self.assertEqual(result, {"updated": 1, "created": 0}) + self.assertEqual(self.order.note, "hi") + + def test_uncastable_value_skips_the_field(self): + result = self.order.apply_field_mappings([self._line("qty", 1, "abc")]) + self.assertEqual(result, {"updated": 0, "created": 0}) + self.assertEqual(self.lines[0].qty, 1) + + def test_mapping_without_chain_is_skipped(self): + result = self.order.apply_field_mappings([{"value": 5}]) + self.assertEqual(result, {"updated": 0, "created": 0}) + + def test_unknown_path_segment_is_skipped(self): + result = self.order.apply_field_mappings( + [{"chain": "nope.qty", "selectors": {"nope": 1}, "value": 4}] + ) + self.assertEqual(result, {"updated": 0, "created": 0}) + + def test_selectors_break_on_unknown_segment(self): + self.assertEqual(self.order._linking_selectors("nope.qty"), []) + + def test_selectors_walk_through_many2one(self): + self.assertEqual(self.order._linking_selectors("partner_id.name"), []) + + def test_link_spreadsheet_is_idempotent(self): + sheet = self.env["spreadsheet.spreadsheet"].create({"name": "S"}) + self.order._link_spreadsheet(sheet) + self.order._link_spreadsheet(sheet) + self.assertEqual(sheet.link_res_model, self.order._name) + self.assertEqual(sheet.link_res_id, self.order.id) + + def test_candidates_and_label_hooks_shape_selectors(self): + self.hooked.line_ids = [ + Command.create({"label": "Keep", "qty": 2}), + Command.create({"label": "Drop", "qty": 3, "skip": True}), + Command.create({"label": False, "qty": 4}), + ] + labels = [ + line["label"] + for line in self.hooked._linking_selectors("line_ids.qty")[0]["labels"] + ] + self.assertNotIn("Drop", labels) + self.assertEqual(labels[:2], ["L1", "Keep"]) + self.assertTrue(labels[2]) + + def test_write_hook_marks_the_line(self): + result = self.hooked.apply_field_mappings( + [{"chain": "line_ids.qty", "selectors": {"line_ids": 1}, "value": 9.0}] + ) + self.assertEqual(result, {"updated": 1, "created": 0}) + self.assertTrue(self.hooked.line_ids[0].touched) + self.assertEqual(self.hooked.line_ids[0].qty, 9.0) + + def test_create_hook_builds_the_line(self): + result = self.hooked.apply_field_mappings( + [{"chain": "line_ids.qty", "selectors": {"line_ids": 2}, "value": 7.0}] + ) + self.assertEqual(result, {"updated": 0, "created": 1}) + created = self.hooked.line_ids.filtered(lambda line: line.touched) + self.assertEqual(created.qty, 7.0) + + +class TestSpreadsheetBridge(TransactionCase): + def test_context_without_target_is_not_linkable(self): + spreadsheet = self.env["spreadsheet.spreadsheet"].create({"name": "S"}) + context = spreadsheet.get_linking_context() + self.assertFalse(context["isLinkable"]) + self.assertFalse(context["writable"]) + + def test_selectors_without_target_is_empty(self): + spreadsheet = self.env["spreadsheet.spreadsheet"].create({"name": "S"}) + self.assertEqual(spreadsheet.get_link_selectors("line_ids.price"), []) + + def test_write_without_target_is_noop(self): + spreadsheet = self.env["spreadsheet.spreadsheet"].create({"name": "S"}) + result = spreadsheet.write_field_mappings([{"chain": "note", "value": "x"}]) + self.assertEqual(result, {"updated": 0, "created": 0}) + + +class TestGenericLinking(TransactionCase): + """Any concrete record is writable without inheriting the mixin.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.partner = cls.env["res.partner"].create({"name": "Partner"}) + cls.sheet = cls.env["spreadsheet.spreadsheet"].create({"name": "S"}) + cls.sheet.write( + {"link_res_model": "res.partner", "link_res_id": cls.partner.id} + ) + + def test_context_resolves_for_non_mixin_model(self): + context = self.sheet.get_linking_context() + self.assertTrue(context["isLinkable"]) + self.assertTrue(context["writable"]) + self.assertEqual(context["model"], "res.partner") + self.assertEqual(context["resId"], self.partner.id) + + def test_write_root_field(self): + result = self.sheet.write_field_mappings([{"chain": "ref", "value": "REF-1"}]) + self.assertEqual(result, {"updated": 1, "created": 0}) + self.assertEqual(self.partner.ref, "REF-1") + + def test_readonly_field_is_skipped(self): + result = self.sheet.write_field_mappings( + [{"chain": "display_name", "value": "Hacked"}] + ) + self.assertEqual(result, {"updated": 0, "created": 0}) + self.assertNotEqual(self.partner.display_name, "Hacked") + + def test_creates_line_through_one2many(self): + result = self.sheet.write_field_mappings( + [ + { + "chain": "child_ids.name", + "selectors": {"child_ids": 1}, + "value": "Child", + } + ] + ) + self.assertEqual(result, {"updated": 0, "created": 1}) + self.assertIn("Child", self.partner.child_ids.mapped("name")) + + def test_many2many_beyond_count_is_not_created(self): + result = self.sheet.write_field_mappings( + [ + { + "chain": "category_id.name", + "selectors": {"category_id": 2}, + "value": "X", + } + ] + ) + self.assertEqual(result, {"updated": 0, "created": 0}) + + +class TestLinkTarget(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.partner = cls.env["res.partner"].create({"name": "Partner"}) + cls.sheet = cls.env["spreadsheet.spreadsheet"].create({"name": "S"}) + + def test_set_link_target_binds_the_record(self): + context = self.sheet.set_link_target("res.partner", self.partner.id) + self.assertEqual(self.sheet.link_res_model, "res.partner") + self.assertEqual(self.sheet.link_res_id, self.partner.id) + # Returns the fresh context (not a reopen action) so the client can + # refresh in place without racing the collaborative session. + self.assertTrue(context["isLinkable"]) + self.assertEqual(context["resId"], self.partner.id) + self.assertEqual(context["spreadsheetId"], self.sheet.id) + + def test_set_link_target_clears_the_link(self): + self.sheet.set_link_target("res.partner", self.partner.id) + self.sheet.set_link_target() + self.assertFalse(self.sheet.link_res_model) + self.assertFalse(self.sheet.link_res_id) + self.assertFalse(self.sheet.get_linking_context()["isLinkable"]) + + def test_linkable_models_excludes_transient_and_abstract(self): + names = self.env["spreadsheet.spreadsheet"].get_linkable_models() + self.assertIn("res.partner", names) + self.assertNotIn("spreadsheet.linkable", names) + self.assertFalse(any(self.env[name]._transient for name in names)) + + def test_linkable_models_respects_write_access(self): + user = self.env["res.users"].create( + { + "name": "Portal", + "login": "sfl_portal", + "group_ids": [Command.link(self.env.ref("base.group_portal").id)], + } + ) + sheet = self.env["spreadsheet.spreadsheet"].with_user(user) + self.assertNotIn("ir.model", sheet.get_linkable_models()) + + def test_dangling_reference_is_not_linkable(self): + self.sheet.write( + {"link_res_model": "res.partner", "link_res_id": self.partner.id} + ) + self.partner.unlink() + self.assertFalse(self.sheet.get_linking_context()["isLinkable"]) diff --git a/spreadsheet_field_linking/tests/test_cast.py b/spreadsheet_field_linking/tests/test_cast.py new file mode 100644 index 00000000..741e62a8 --- /dev/null +++ b/spreadsheet_field_linking/tests/test_cast.py @@ -0,0 +1,82 @@ +# Copyright 2026 arielbarreiros96 +# License AGPL-3.0 or later (https://www.gnu.org/licenses/AGPL-3.0). + +from datetime import date, datetime +from types import SimpleNamespace + +from odoo import Command +from odoo.tests.common import TransactionCase + +from odoo.addons.spreadsheet_field_linking.models.spreadsheet_linkable import _cast + + +def cast(ftype, value): + return _cast(SimpleNamespace(type=ftype), value) + + +class TestCast(TransactionCase): + def test_numeric(self): + self.assertEqual(cast("float", "3.5"), 3.5) + self.assertEqual(cast("float", 2), 2.0) + self.assertEqual(cast("monetary", "10"), 10.0) + self.assertEqual(cast("integer", 2.7), 2) + self.assertEqual(cast("integer", "4"), 4) + + def test_numeric_zero_is_written(self): + self.assertEqual(cast("float", 0), 0.0) + self.assertEqual(cast("integer", 0), 0) + + def test_uncastable_numbers_are_skipped(self): + self.assertIsNone(cast("float", "abc")) + self.assertIsNone(cast("integer", "abc")) + + def test_boolean(self): + for falsy in ("FALSE", "false", "no", "n", "0", 0): + self.assertIs(cast("boolean", falsy), False, falsy) + for truthy in ("TRUE", "yes", "1", 1, "anything"): + self.assertIs(cast("boolean", truthy), True, truthy) + + def test_text(self): + self.assertEqual(cast("char", 12), "12") + self.assertEqual(cast("text", "hello"), "hello") + self.assertEqual(cast("html", 3.5), "3.5") + + def test_selection(self): + self.assertEqual(cast("selection", 1.0), "1") + self.assertEqual(cast("selection", 2), "2") + self.assertEqual(cast("selection", "draft"), "draft") + + def test_date_and_datetime_from_serial(self): + self.assertEqual(cast("date", 45292), date(2024, 1, 1)) + self.assertEqual(cast("datetime", 45292.25), datetime(2024, 1, 1, 6, 0, 0)) + + def test_date_and_datetime_from_string(self): + self.assertEqual( + cast("datetime", "2024-01-01 06:00:00"), datetime(2024, 1, 1, 6, 0, 0) + ) + self.assertEqual(cast("date", "2024-01-01 00:00:00"), date(2024, 1, 1)) + self.assertIsNone(cast("datetime", "not-a-date")) + + def test_many2one(self): + self.assertEqual(cast("many2one", 7), 7) + self.assertEqual(cast("many2one", 7.0), 7) + self.assertIsNone(cast("many2one", 7.4)) + + def test_x2many_ids(self): + expected = [Command.set([1, 2, 3])] + self.assertEqual(cast("one2many", "1,2,3"), expected) + self.assertEqual(cast("many2many", [1, 2, 3]), expected) + self.assertIsNone(cast("many2many", "abc")) + + def test_empty_clears_scalars(self): + for ftype in ("float", "integer", "char", "boolean", "date", "many2one"): + self.assertIs(cast(ftype, ""), False, ftype) + self.assertIs(cast(ftype, None), False, ftype) + + def test_empty_clears_x2many(self): + self.assertEqual(cast("one2many", ""), [Command.clear()]) + self.assertEqual(cast("many2many", None), [Command.clear()]) + + def test_unhandled_type_is_skipped(self): + self.assertIsNone(cast("binary", "x")) + self.assertIsNone(cast("json", {"a": 1})) diff --git a/spreadsheet_field_linking/tests/test_models.py b/spreadsheet_field_linking/tests/test_models.py new file mode 100644 index 00000000..31abaa4c --- /dev/null +++ b/spreadsheet_field_linking/tests/test_models.py @@ -0,0 +1,71 @@ +from odoo import api, fields, models +from odoo.exceptions import UserError + + +class FieldLinkingTestOrder(models.Model): + _name = "field.linking.test.order" + _description = "Field Linking Test Order" + _inherit = "spreadsheet.linkable" + + name = fields.Char() + note = fields.Char() + state = fields.Selection([("draft", "Draft"), ("done", "Done")], default="draft") + total = fields.Float(compute="_compute_total", store=True) + partner_id = fields.Many2one("res.partner") + line_ids = fields.One2many("field.linking.test.line", "order_id") + + @api.depends("line_ids.price") + def _compute_total(self): + for order in self: + order.total = sum(order.line_ids.mapped("price")) + + def _check_linking_allowed(self): + self.ensure_one() + if self.state == "done": + raise UserError(self.env._("Cannot write values into a done order.")) + + +class FieldLinkingTestLine(models.Model): + _name = "field.linking.test.line" + _description = "Field Linking Test Line" + + order_id = fields.Many2one("field.linking.test.order", ondelete="cascade") + partner_id = fields.Many2one("res.partner") + label = fields.Char() + qty = fields.Float() + price = fields.Float() + display_type = fields.Char() + + +class FieldLinkingHookedOrder(models.Model): + _name = "field.linking.hooked.order" + _description = "Field Linking Hooked Order" + _inherit = "spreadsheet.linkable" + + name = fields.Char() + line_ids = fields.One2many("field.linking.hooked.line", "order_id") + + def _linking_candidates(self, records, segment): + return records.filtered(lambda line: not line.skip) + + def _linking_label(self, line): + return line.label + + def _linking_write(self, record, values): + return record.write({**values, "touched": True}) + + def _create_linked_line(self, parent, segment, values): + return parent.env["field.linking.hooked.line"].create( + {"order_id": parent.id, "touched": True, **values} + ) + + +class FieldLinkingHookedLine(models.Model): + _name = "field.linking.hooked.line" + _description = "Field Linking Hooked Line" + + order_id = fields.Many2one("field.linking.hooked.order", ondelete="cascade") + label = fields.Char() + qty = fields.Float() + skip = fields.Boolean() + touched = fields.Boolean() diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 00000000..0ffa498d --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1 @@ +odoo-addon-spreadsheet_oca @ git+https://github.com/OCA/spreadsheet.git@refs/pull/127/head#subdirectory=spreadsheet_oca