Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 24 additions & 10 deletions spreadsheet_oca/README.rst
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
.. image:: https://odoo-community.org/readme-banner-image
:target: https://odoo-community.org/get-involved?utm_source=readme
:alt: Odoo Community Association

===============
Spreadsheet Oca
===============
Expand All @@ -17,7 +13,7 @@ Spreadsheet Oca
.. |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
.. |badge2| image:: https://img.shields.io/badge/licence-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
Expand All @@ -32,11 +28,29 @@ Spreadsheet Oca

|badge1| |badge2| |badge3| |badge4| |badge5|

This module adds a functionality for adding and editing Spreadsheets
using Odoo CE.

It is an alternative to the proprietary module ``spreadsheet_edition``
of Odoo Enterprise Edition.
This module provides a full-featured spreadsheet editor for Odoo CE using
the ``o-spreadsheet`` engine. It serves as a community alternative that
requires only Odoo CE and OCA dependencies.

Beyond basic spreadsheet editing, the module includes server-side features
for operational use:

- **Scheduled Refresh** — cron-based pivot data refresh with email digest
notifications and input parameter substitution in domains
- **KPI Alerts** — cell-value threshold monitors with edge or level trigger
modes, sending notifications when conditions are met
- **What-If Scenarios** — named cell-override sets for scenario planning,
with comparison export and apply-to-copy workflow
- **Email Subscriptions** — partner-level daily/weekly/monthly digest emails
with optional pivot data summaries
- **Input Parameters** — named cell registry for domain token substitution
(e.g. ``%(start_date)s``) used by scheduled refresh and alerts
- **Cell Writeback** — edit Odoo record fields directly from list-view cells
in the spreadsheet, with full audit trail and rollback
- **XLSX Export** — server-rendered ``.xlsx`` download with fresh pivot data
on dedicated sheets, styled headers, and static cell content
- **Collaborative Editing** — revision-based multi-user editing with conflict
resolution via the OWL-based spreadsheet component

**Table of contents**

Expand Down
16 changes: 14 additions & 2 deletions spreadsheet_oca/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,27 @@
"name": "Spreadsheet Oca",
"summary": """
Allow to edit spreadsheets""",
"version": "18.0.1.2.3",
"version": "18.0.2.0.0",
"license": "AGPL-3",
"author": "CreuBlanca,Odoo Community Association (OCA)",
"website": "https://github.com/OCA/spreadsheet",
"depends": ["spreadsheet", "base_sparse_field", "bus"],
"depends": ["spreadsheet", "base_sparse_field", "bus", "web_tour"],
"data": [
"security/security.xml",
"security/ir.model.access.csv",
"views/spreadsheet_spreadsheet.xml",
"views/spreadsheet_refresh_schedule_views.xml",
"views/spreadsheet_alert_views.xml",
"views/spreadsheet_subscription_views.xml",
"views/spreadsheet_scenario_views.xml",
"views/spreadsheet_xlsx_export_views.xml",
"views/spreadsheet_writeback_views.xml",
"views/spreadsheet_input_param_views.xml",
"data/mail_templates.xml",
"data/spreadsheet_spreadsheet_import_mode.xml",
"data/spreadsheet_alert_cron.xml",
"data/spreadsheet_subscription_cron.xml",
"data/web_tour_tour.xml",
"wizards/spreadsheet_select_row_number.xml",
"wizards/spreadsheet_spreadsheet_import.xml",
],
Expand All @@ -28,6 +39,7 @@
"spreadsheet_oca/static/src/spreadsheet/list_controller.esm.js",
"spreadsheet_oca/static/src/spreadsheet/list_renderer.esm.js",
"spreadsheet_oca/static/src/spreadsheet/list_controller.xml",
"spreadsheet_oca/static/src/tours/spreadsheet_feature_tour.esm.js",
],
"web.assets_backend_lazy": [
"spreadsheet_oca/static/src/spreadsheet/pivot_controller.esm.js",
Expand Down
2 changes: 2 additions & 0 deletions spreadsheet_oca/controllers/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
from . import main
from . import spreadsheet_writeback
from . import spreadsheet_input_params
31 changes: 31 additions & 0 deletions spreadsheet_oca/controllers/spreadsheet_input_params.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Copyright 2025 Ledo Enterprises LLC
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
"""
JSON endpoint for spreadsheet input parameters.

Provides a lightweight API so that a future JS plugin can read the current
named-parameter values without having to parse spreadsheet_raw itself.
"""

from odoo.http import Controller, request, route


class SpreadsheetInputParamsController(Controller):
@route(
"/spreadsheet/input_params/<int:spreadsheet_id>",
type="json",
auth="user",
)
def get_input_params(self, spreadsheet_id):
"""Return {name: current_value} for all active parameters."""
spreadsheet = request.env["spreadsheet.spreadsheet"].browse(spreadsheet_id)
if not spreadsheet.exists():
return {"error": "Spreadsheet not found."}
spreadsheet.check_access("read")
params = request.env["spreadsheet.input_param"].search(
[
("spreadsheet_id", "=", spreadsheet_id),
("active", "=", True),
]
)
return {p.name: p.current_value for p in params}
183 changes: 183 additions & 0 deletions spreadsheet_oca/controllers/spreadsheet_writeback.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
# Copyright 2025 Ledo Enterprises LLC
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
"""
Controller for cell writeback: edit list cells to update Odoo records.

The JS list cell-edit handler POSTs here with:
spreadsheet_id — int
model — str (e.g. "sale.order")
record_id — int
field_name — str (e.g. "name")
new_value — any (JSON-decoded by Odoo's JSON-RPC dispatcher)

Returns a JSON-serialisable dict:
{'success': True, 'old_value': str, 'new_value': str, 'log_id': int}
or
{'error': '<message>'}

All exceptions are caught so a writeback failure never results in a
500 error reaching the browser.
"""

import logging

from odoo import _
from odoo.exceptions import AccessError
from odoo.http import Controller, request, route

_logger = logging.getLogger(__name__)


class SpreadsheetWriteback(Controller):
@route(
"/spreadsheet/writeback",
type="json",
auth="user",
methods=["POST"],
)
def writeback(self, spreadsheet_id, model, record_id, field_name, new_value):
"""
Write a single field value to an Odoo record on behalf of the
spreadsheet's List cell-edit handler.

Security checks (in order):
1. spreadsheet.writeback_enabled must be True.
2. Current user must have read access on the spreadsheet record.
3. model must be a registered model in this environment.
4. The target record must exist.
5. Current user must have write access on the target record.

The old value is captured before the write and stored in the audit
log. For relational fields str() is used which may not be
directly re-writable; see the model docstring for details.
"""
log_vals_base = {
"spreadsheet_id": spreadsheet_id,
"res_model": model,
"record_id": record_id,
"field_name": field_name,
"new_value": str(new_value),
}

try:
# 1. Load spreadsheet and check writeback_enabled
spreadsheet = request.env["spreadsheet.spreadsheet"].browse(spreadsheet_id)
if not spreadsheet.exists():
return {"error": "Spreadsheet not found."}

if not spreadsheet.writeback_enabled:
return {"error": "Writeback not enabled for this spreadsheet."}

# 2. Check spreadsheet read access
try:
spreadsheet.check_access("read")
except AccessError:
return {"error": "Access denied to spreadsheet."}

# 3. Validate model
if model not in request.env:
return {"error": f"Model {model!r} is not available."}

# 4. Load and check record existence
record = request.env[model].browse(record_id)
if not record.exists():
return {"error": f"Record {model}({record_id}) not found."}

# 5. Check write access on the target record
try:
record.check_access("write")
except AccessError:
_logger.warning(
"Writeback: user %d denied write on %s(%d)",
request.env.uid,
model,
record_id,
)
return {"error": "Access denied: no write access on record."}

# 6. Validate field_name exists and is writable
model_fields = request.env[model]._fields
if field_name not in model_fields:
return {
"error": _(
"Field %(field)s does not exist on model %(model)s.",
field=field_name,
model=model,
)
}
field_obj = model_fields[field_name]
if field_obj.readonly or field_obj.compute:
return {
"error": _(
"Field %(field)s on %(model)s is computed or readonly"
" and cannot be written to.",
field=field_name,
model=model,
)
}

# Capture old value before writing
old_value = record[field_name]
old_value_str = str(old_value)

# Perform the write
record.write({field_name: new_value})

# Create audit log (sudo so the log can always be written
# regardless of the user's access on spreadsheet.writeback.log)
log = (
request.env["spreadsheet.writeback.log"]
.sudo()
.create(
dict(
log_vals_base,
old_value=old_value_str,
status="ok",
)
)
)

# Post a brief chatter note on the spreadsheet
spreadsheet.sudo().message_post(
body=_(
"Writeback: field <b>%(field)s</b> on "
"<b>%(model)s</b> #%(record_id)d changed "
"from <b>%(old)s</b> to <b>%(new)s</b>.",
field=field_name,
model=model,
record_id=record_id,
old=old_value_str,
new=str(new_value),
),
subtype_xmlid="mail.mt_note",
)

return {
"success": True,
"old_value": old_value_str,
"new_value": str(new_value),
"log_id": log.id,
}

except Exception as exc:
_logger.exception(
"Writeback error: spreadsheet=%d model=%s record=%d field=%s",
spreadsheet_id,
model,
record_id,
field_name,
)
# Attempt to write an error log (best effort — use sudo and
# ignore any secondary failure so the route always returns JSON)
try:
request.env["spreadsheet.writeback.log"].sudo().create(
dict(
log_vals_base,
status="error",
error_message=str(exc)[:255],
)
)
except Exception:
_logger.exception("Failed to create writeback error log")

return {"error": str(exc)}
Loading
Loading