diff --git a/dms_spreadsheet/README.rst b/dms_spreadsheet/README.rst new file mode 100644 index 000000000..307dbffbf --- /dev/null +++ b/dms_spreadsheet/README.rst @@ -0,0 +1,106 @@ +================= +DMS Spreadsheet +================= + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:placeholder + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: https://www.gnu.org/licenses/agpl + :alt: License: AGPL-3 + +|badge1| + +Create and edit OCA spreadsheets directly within the DMS file manager. + +Requires ``dms`` and ``spreadsheet_oca``. + +**Features** + +* **New Spreadsheet** button in DMS directory views (form and kanban) +* Opening a ``.o-spreadsheet`` file launches the full OCA spreadsheet editor +* Spreadsheet data stored transparently via DMS file storage (database, + attachment, or filesystem) +* Read-only mode for users without write access to the DMS file +* Revision history via ``spreadsheet.abstract`` mixin + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +**Creating a new spreadsheet** + +1. Open **DMS** → navigate to the target directory. +2. Click the **New Spreadsheet** button (available in the form header and the + kanban directory card). +3. Enter a name and confirm — the OCA spreadsheet editor opens immediately. +4. Edit your spreadsheet; changes are saved automatically via the revision + protocol. + +**Opening an existing spreadsheet** + +1. Browse to the directory containing the file. +2. Open the file record; click **Open Spreadsheet** in the form header. + Alternatively, double-click the file in the kanban/list view — if the MIME + type is ``application/o-spreadsheet`` the editor is launched directly. + +**Read-only access** + +Users who have read but not write access to a DMS file will have the +spreadsheet opened in **read-only** mode (the editor toolbar is disabled). + +Configuration +============= + +No additional configuration is required after installing the module. + +Spreadsheet files are identified by the MIME type +``application/o-spreadsheet`` and the ``handler`` field value ``spreadsheet`` +on ``dms.file`` records. Both are set automatically when a file is created +through the **New Spreadsheet** wizard or when an existing DMS file's MIME +type is set to ``application/o-spreadsheet``. + +Access to individual spreadsheets is governed by the existing DMS permission +system (storage-level groups and directory-level ACLs). + +Known issues / Roadmap +====================== + +* Template library: pre-built spreadsheet templates selectable from the + creation wizard. +* Import XLSX: convert an uploaded Excel file to an OCA spreadsheet in-place. +* Thumbnail preview: render a small image of the spreadsheet content as the + DMS file thumbnail. +* Collaborative editing: expose the OCA collaborative-revision WebSocket + endpoint for DMS files. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. + +Credits +======= + +Authors +~~~~~~~ + +* Ledo Enterprises + +Contributors +~~~~~~~~~~~~ + +* Ledo Enterprises + +Maintainers +~~~~~~~~~~~ + +This module is part of the `OCA/dms `_ project. diff --git a/dms_spreadsheet/__init__.py b/dms_spreadsheet/__init__.py new file mode 100644 index 000000000..a9d39dfc5 --- /dev/null +++ b/dms_spreadsheet/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026 Ledo Enterprises +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import models, wizard diff --git a/dms_spreadsheet/__manifest__.py b/dms_spreadsheet/__manifest__.py new file mode 100644 index 000000000..78c6f9492 --- /dev/null +++ b/dms_spreadsheet/__manifest__.py @@ -0,0 +1,24 @@ +# Copyright 2026 Ledo Enterprises +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "DMS Spreadsheet", + "summary": "Create and edit spreadsheets directly within the DMS file manager", + "version": "18.0.1.0.0", + "category": "Document Management", + "license": "AGPL-3", + "author": "Ledo Enterprises, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/dms", + "depends": [ + "dms", + "spreadsheet_oca", + ], + "data": [ + "security/ir.model.access.csv", + "wizard/dms_spreadsheet_create_views.xml", + "views/dms_file_views.xml", + "views/dms_directory_views.xml", + ], + "installable": True, + "auto_install": False, +} diff --git a/dms_spreadsheet/models/__init__.py b/dms_spreadsheet/models/__init__.py new file mode 100644 index 000000000..409773c08 --- /dev/null +++ b/dms_spreadsheet/models/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026 Ledo Enterprises +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import dms_directory, dms_file diff --git a/dms_spreadsheet/models/dms_directory.py b/dms_spreadsheet/models/dms_directory.py new file mode 100644 index 000000000..e3f3cb4fb --- /dev/null +++ b/dms_spreadsheet/models/dms_directory.py @@ -0,0 +1,20 @@ +# Copyright 2026 Ledo Enterprises +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import models + + +class DmsDirectory(models.Model): + _inherit = "dms.directory" + + def action_new_spreadsheet(self): + """Open the New Spreadsheet wizard pre-filled with this directory.""" + self.ensure_one() + return { + "type": "ir.actions.act_window", + "name": "New Spreadsheet", + "res_model": "dms.spreadsheet.create", + "view_mode": "form", + "target": "new", + "context": {"default_directory_id": self.id}, + } diff --git a/dms_spreadsheet/models/dms_file.py b/dms_spreadsheet/models/dms_file.py new file mode 100644 index 000000000..bce5e39b8 --- /dev/null +++ b/dms_spreadsheet/models/dms_file.py @@ -0,0 +1,145 @@ +# Copyright 2026 Ledo Enterprises +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import base64 +import json +import logging + +from odoo import api, fields, models +from odoo.exceptions import AccessError + +_logger = logging.getLogger(__name__) + +SPREADSHEET_MIMETYPE = "application/o-spreadsheet" + + +class DmsFile(models.Model): + _name = "dms.file" + _inherit = ["dms.file", "spreadsheet.abstract"] + + handler = fields.Selection( + selection=[("spreadsheet", "Spreadsheet")], + index=True, + ) + + # ── Lifecycle ──────────────────────────────────────────────────────────── + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if vals.get("mimetype") == SPREADSHEET_MIMETYPE: + vals["handler"] = "spreadsheet" + if vals.get("content") and not vals.get("spreadsheet_binary_data"): + vals["spreadsheet_binary_data"] = vals["content"] + return super().create(vals_list) + + def write(self, vals): + if vals.get("mimetype") == SPREADSHEET_MIMETYPE: + vals["handler"] = "spreadsheet" + return super().write(vals) + + # ── Storage bridge ─────────────────────────────────────────────────────── + + def _compute_content(self): + """For spreadsheet files, content mirrors spreadsheet_binary_data.""" + spreadsheets = self.filtered(lambda f: f.handler == "spreadsheet") + for rec in spreadsheets: + rec.content = rec.spreadsheet_binary_data + return super(DmsFile, self - spreadsheets)._compute_content() + + def _inverse_content(self): + """For spreadsheet files, write-through to spreadsheet_binary_data.""" + spreadsheets = self.filtered(lambda f: f.handler == "spreadsheet") + for rec in spreadsheets: + rec.spreadsheet_binary_data = rec.content + return super(DmsFile, self - spreadsheets)._inverse_content() + + # ── spreadsheet.abstract interface ─────────────────────────────────────── + + def get_spreadsheet_data(self): + """Return data payload consumed by action_spreadsheet_oca client action.""" + self.ensure_one() + mode = "normal" + try: + self.check_access("write") + except AccessError: + mode = "readonly" + + raw = {} + if self.spreadsheet_binary_data: + try: + raw = json.loads( + base64.decodebytes(self.spreadsheet_binary_data).decode("utf-8") + ) + except (ValueError, UnicodeDecodeError): + _logger.warning( + "Could not decode spreadsheet data for dms.file %s", self.id + ) + + revisions = [] + for rev in self.spreadsheet_revision_ids: + try: + revisions.append( + dict( + json.loads(rev.commands), + nextRevisionId=rev.next_revision_id, + serverRevisionId=rev.server_revision_id, + ) + ) + except (ValueError, UnicodeDecodeError): + _logger.warning( + "Skipping malformed revision %s on dms.file %s", rev.id, self.id + ) + + return { + "name": self.name, + "spreadsheet_raw": raw, + "revisions": revisions, + "mode": mode, + "default_currency": self.env[ + "res.currency" + ].get_company_currency_for_spreadsheet(), + "user_locale": self.env["res.lang"]._get_user_spreadsheet_locale(), + } + + def open_spreadsheet(self): + self.ensure_one() + return { + "type": "ir.actions.client", + "tag": "action_spreadsheet_oca", + "params": {"spreadsheet_id": self.id, "model": self._name}, + } + + @api.model + def action_open_new_spreadsheet(self): + """Open the New Spreadsheet wizard, pre-filling directory from context. + + Called from the server action bound to the file kanban/list views so + that 'New Spreadsheet' appears in the Files ⚙ action menu. + """ + ctx = self.env.context + directory_id = ctx.get("default_directory_id") or ctx.get("active_id") + return { + "type": "ir.actions.act_window", + "res_model": "dms.spreadsheet.create", + "view_mode": "form", + "target": "new", + "context": {"default_directory_id": directory_id}, + } + + # ── Computed helpers ───────────────────────────────────────────────────── + + @api.depends("handler") + def _compute_extension(self): + """Spreadsheet files use 'oss' extension (MIME not in Python stdlib).""" + spreadsheets = self.filtered(lambda f: f.handler == "spreadsheet") + for rec in spreadsheets: + rec.extension = "oss" + return super(DmsFile, self - spreadsheets)._compute_extension() + + @api.depends("handler") + def _compute_mimetype(self): + spreadsheets = self.filtered(lambda f: f.handler == "spreadsheet") + for rec in spreadsheets: + rec.mimetype = SPREADSHEET_MIMETYPE + return super(DmsFile, self - spreadsheets)._compute_mimetype() diff --git a/dms_spreadsheet/pyproject.toml b/dms_spreadsheet/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/dms_spreadsheet/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/dms_spreadsheet/readme/CONFIGURE.md b/dms_spreadsheet/readme/CONFIGURE.md new file mode 100644 index 000000000..b38ae77b5 --- /dev/null +++ b/dms_spreadsheet/readme/CONFIGURE.md @@ -0,0 +1,9 @@ +No additional configuration is required after installing the module. + +Spreadsheet files are identified by the MIME type `application/o-spreadsheet` and the +`handler` field value `spreadsheet` on `dms.file` records. Both are set automatically +when a file is created through the **New Spreadsheet** wizard or when an existing DMS +file's MIME type is set to `application/o-spreadsheet`. + +Access to individual spreadsheets is governed by the existing DMS permission system +(storage-level groups and directory-level ACLs). diff --git a/dms_spreadsheet/readme/CONTRIBUTORS.md b/dms_spreadsheet/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..d062226c2 --- /dev/null +++ b/dms_spreadsheet/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Ledo Enterprises diff --git a/dms_spreadsheet/readme/DESCRIPTION.md b/dms_spreadsheet/readme/DESCRIPTION.md new file mode 100644 index 000000000..14e74e127 --- /dev/null +++ b/dms_spreadsheet/readme/DESCRIPTION.md @@ -0,0 +1,11 @@ +Create and edit OCA spreadsheets directly within the DMS file manager. + +Requires `dms` and `spreadsheet_oca`. + +**Features** + +- **New Spreadsheet** button in DMS directory views (form and kanban) +- Opening a `.o-spreadsheet` file launches the full OCA spreadsheet editor +- Spreadsheet data stored transparently via DMS file storage (database, attachment, or filesystem) +- Read-only mode for users without write access to the DMS file +- Revision history via `spreadsheet.abstract` mixin diff --git a/dms_spreadsheet/readme/ROADMAP.md b/dms_spreadsheet/readme/ROADMAP.md new file mode 100644 index 000000000..374253341 --- /dev/null +++ b/dms_spreadsheet/readme/ROADMAP.md @@ -0,0 +1,4 @@ +- Template library: pre-built spreadsheet templates selectable from the creation wizard. +- Import XLSX: convert an uploaded Excel file to an OCA spreadsheet in-place. +- Thumbnail preview: render a small image of the spreadsheet content as the DMS file thumbnail. +- Collaborative editing: expose the OCA collaborative-revision WebSocket endpoint for DMS files. diff --git a/dms_spreadsheet/readme/USAGE.md b/dms_spreadsheet/readme/USAGE.md new file mode 100644 index 000000000..2d6c8e9b1 --- /dev/null +++ b/dms_spreadsheet/readme/USAGE.md @@ -0,0 +1,18 @@ +**Creating a new spreadsheet** + +1. Open **DMS** → navigate to the target directory. +2. Click the **New Spreadsheet** button (available in the form header and the kanban directory card). +3. Enter a name and confirm — the OCA spreadsheet editor opens immediately. +4. Edit your spreadsheet; changes are saved automatically via the revision protocol. + +**Opening an existing spreadsheet** + +1. Browse to the directory containing the file. +2. Open the file record; click **Open Spreadsheet** in the form header. + Alternatively, double-click the file in the kanban/list view — if the MIME type is + `application/o-spreadsheet` the editor is launched directly. + +**Read-only access** + +Users who have read but not write access to a DMS file will have the spreadsheet opened +in **read-only** mode (the editor toolbar is disabled). diff --git a/dms_spreadsheet/security/ir.model.access.csv b/dms_spreadsheet/security/ir.model.access.csv new file mode 100644 index 000000000..d9aaa41dc --- /dev/null +++ b/dms_spreadsheet/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_dms_spreadsheet_create_user,dms.spreadsheet.create user,model_dms_spreadsheet_create,base.group_user,1,1,1,1 diff --git a/dms_spreadsheet/static/description/icon.png b/dms_spreadsheet/static/description/icon.png new file mode 100644 index 000000000..26d62db69 Binary files /dev/null and b/dms_spreadsheet/static/description/icon.png differ diff --git a/dms_spreadsheet/tests/__init__.py b/dms_spreadsheet/tests/__init__.py new file mode 100644 index 000000000..190352aa9 --- /dev/null +++ b/dms_spreadsheet/tests/__init__.py @@ -0,0 +1 @@ +from . import test_dms_spreadsheet diff --git a/dms_spreadsheet/tests/test_dms_spreadsheet.py b/dms_spreadsheet/tests/test_dms_spreadsheet.py new file mode 100644 index 000000000..a98639c8d --- /dev/null +++ b/dms_spreadsheet/tests/test_dms_spreadsheet.py @@ -0,0 +1,194 @@ +# Copyright 2026 Ledo Enterprises +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import base64 +import json + +from odoo.tests import new_test_user + +from odoo.addons.base.tests.common import BaseCommon +from odoo.addons.dms_spreadsheet.models.dms_file import SPREADSHEET_MIMETYPE + + +class TestDmsSpreadsheet(BaseCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.storage = cls.env["dms.storage"].create( + {"name": "Test Storage", "save_type": "database"} + ) + cls.directory = cls.env["dms.directory"].create( + { + "name": "Test Directory", + "storage_id": cls.storage.id, + "is_root_directory": True, + } + ) + # readonly_user: member of DMS access group (can read) but no write access + cls.readonly_user = new_test_user(cls.env, login="dms-readonly") + cls.access_group = cls.env["dms.access.group"].create( + { + "name": "Test Read-Only", + "perm_write": False, + "perm_create": False, + "explicit_user_ids": [(4, cls.readonly_user.id)], + } + ) + cls.directory.group_ids = [(4, cls.access_group.id)] + + def _make_spreadsheet_file(self, name="Test Sheet", content=None): + vals = { + "name": name, + "directory_id": self.directory.id, + "mimetype": SPREADSHEET_MIMETYPE, + } + if content is not None: + vals["spreadsheet_binary_data"] = content + return self.env["dms.file"].create(vals) + + # ── Creation ───────────────────────────────────────────────────────────── + + def test_create_sets_handler(self): + """Creating a dms.file with MIME type sets handler='spreadsheet'.""" + file = self._make_spreadsheet_file() + self.assertEqual(file.handler, "spreadsheet") + + def test_create_non_spreadsheet_no_handler(self): + """A regular dms.file has no handler set.""" + file = self.env["dms.file"].create( + { + "name": "plain.txt", + "directory_id": self.directory.id, + "content": base64.b64encode(b"hello"), + } + ) + self.assertFalse(file.handler) + + # ── open_spreadsheet action ─────────────────────────────────────────────── + + def test_open_spreadsheet_action(self): + """open_spreadsheet() returns a client action with the correct tag.""" + file = self._make_spreadsheet_file() + action = file.open_spreadsheet() + self.assertEqual(action["type"], "ir.actions.client") + self.assertEqual(action["tag"], "action_spreadsheet_oca") + self.assertEqual(action["params"]["spreadsheet_id"], file.id) + self.assertEqual(action["params"]["model"], "dms.file") + + # ── get_spreadsheet_data ────────────────────────────────────────────────── + + def test_get_spreadsheet_data_empty(self): + """A brand-new spreadsheet returns data dict, no revisions, normal mode.""" + file = self._make_spreadsheet_file() + data = file.get_spreadsheet_data() + # spreadsheet.abstract may initialise spreadsheet_binary_data with a + # default empty document — we just verify it's a dict + self.assertIsInstance(data["spreadsheet_raw"], dict) + self.assertEqual(data["revisions"], []) + self.assertEqual(data["mode"], "normal") + self.assertEqual(data["name"], "Test Sheet") + + def test_get_spreadsheet_data_with_content(self): + """A file with JSON content returns the decoded dict.""" + payload = {"version": 1, "sheets": []} + encoded = base64.b64encode(json.dumps(payload).encode()) + file = self._make_spreadsheet_file(content=encoded) + data = file.get_spreadsheet_data() + self.assertEqual(data["spreadsheet_raw"], payload) + + def test_get_spreadsheet_data_readonly(self): + """A user with read-only DMS access gets mode='readonly'.""" + file = self._make_spreadsheet_file() + data = file.with_user(self.readonly_user).get_spreadsheet_data() + self.assertEqual(data["mode"], "readonly") + + # ── Content bridge ──────────────────────────────────────────────────────── + + def test_content_bridge_compute(self): + """Reading content on a spreadsheet file returns spreadsheet_binary_data.""" + payload = base64.b64encode(json.dumps({"test": True}).encode()) + file = self._make_spreadsheet_file(content=payload) + self.assertEqual(file.content, payload) + + def test_content_bridge_inverse(self): + """Writing content on a spreadsheet file updates spreadsheet_binary_data.""" + file = self._make_spreadsheet_file() + new_payload = base64.b64encode(json.dumps({"updated": True}).encode()) + file.content = new_payload + self.assertEqual(file.spreadsheet_binary_data, new_payload) + + def test_non_spreadsheet_content_unaffected(self): + """Regular files use the normal content compute path.""" + raw = b"plain text" + encoded = base64.b64encode(raw) + file = self.env["dms.file"].create( + { + "name": "notes.txt", + "directory_id": self.directory.id, + "content": encoded, + } + ) + # handler is not 'spreadsheet', so spreadsheet bridge must not interfere + self.assertNotEqual(file.handler, "spreadsheet") + # content should round-trip correctly + self.assertEqual(base64.b64decode(file.content), raw) + + # ── Mimetype ────────────────────────────────────────────────────────────── + + def test_mimetype_forced_for_spreadsheet_handler(self): + """handler='spreadsheet' forces mimetype to application/o-spreadsheet.""" + file = self._make_spreadsheet_file() + file.invalidate_recordset() + self.assertEqual(file.mimetype, SPREADSHEET_MIMETYPE) + + def test_write_sets_handler_on_mimetype_change(self): + """Updating mimetype to o-spreadsheet via write() sets handler.""" + file = self.env["dms.file"].create( + { + "name": "doc.txt", + "directory_id": self.directory.id, + "content": base64.b64encode(b"x"), + } + ) + self.assertFalse(file.handler) + file.write({"mimetype": SPREADSHEET_MIMETYPE}) + self.assertEqual(file.handler, "spreadsheet") + + def test_get_spreadsheet_data_corrupted(self): + """Corrupted binary data does not crash — returns empty dict.""" + file = self._make_spreadsheet_file(content=base64.b64encode(b"not valid json")) + with self.assertLogs("odoo.addons.dms_spreadsheet.models.dms_file", "WARNING"): + data = file.get_spreadsheet_data() + self.assertEqual(data["spreadsheet_raw"], {}) + + # ── Wizard ──────────────────────────────────────────────────────────────── + + def test_wizard_create_spreadsheet(self): + """Wizard creates dms.file with correct attributes and opens editor.""" + wizard = self.env["dms.spreadsheet.create"].create( + { + "name": "Quarterly Budget", + "directory_id": self.directory.id, + } + ) + action = wizard.action_create_spreadsheet() + created = self.env["dms.file"].search( + [ + ("name", "=", "Quarterly Budget"), + ("directory_id", "=", self.directory.id), + ] + ) + self.assertTrue(created) + self.assertEqual(created.handler, "spreadsheet") + self.assertEqual(created.mimetype, SPREADSHEET_MIMETYPE) + self.assertEqual(action["type"], "ir.actions.client") + self.assertEqual(action["tag"], "action_spreadsheet_oca") + + def test_wizard_default_get_from_context(self): + """Wizard pre-fills directory_id from context.""" + wizard = ( + self.env["dms.spreadsheet.create"] + .with_context(default_directory_id=self.directory.id) + .new({}) + ) + self.assertEqual(wizard.directory_id, self.directory) diff --git a/dms_spreadsheet/views/dms_directory_views.xml b/dms_spreadsheet/views/dms_directory_views.xml new file mode 100644 index 000000000..b90d74dff --- /dev/null +++ b/dms_spreadsheet/views/dms_directory_views.xml @@ -0,0 +1,39 @@ + + + + + dms.directory.kanban.spreadsheet + dms.directory + + + + + + + + + + dms.directory.form.spreadsheet + dms.directory + + + +