diff --git a/auditlog_clickhouse_read/README.rst b/auditlog_clickhouse_read/README.rst new file mode 100644 index 00000000000..e2a342c61ea --- /dev/null +++ b/auditlog_clickhouse_read/README.rst @@ -0,0 +1,203 @@ +=========================================== +Read auditlog records stored in clickhouse. +=========================================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:7ab4f4be05c697769b63a62b6d1c7b080afe57e7a48a9c02ef74ba4683853f50 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/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%2Fserver--tools-lightgray.png?logo=github + :target: https://github.com/OCA/server-tools/tree/18.0/auditlog_clickhouse_read + :alt: OCA/server-tools +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/server-tools-18-0/server-tools-18-0-auditlog_clickhouse_read + :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/server-tools&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module extends the audit log integration with ClickHouse to let +Odoo read audit log data through PostgreSQL Foreign Data Wrapper (FDW). + +When FDW read mode is enabled, standard Odoo audit log views continue to +work without additional user tools or direct database access, while the +data is read from ClickHouse. Audit log records become read-only in Odoo +while this mode is active. + +**Table of contents** + +.. contents:: + :local: + +Use Cases / Context +=================== + +Business need: + +In some deployments, audit logs grow quickly and become expensive to +keep and query only in PostgreSQL. At the same time, auditors and +administrators still need to review audit trails from the standard Odoo +interface without learning new tools or getting direct access to +external databases. + +This module is useful in environments where audit log storage is moved +to ClickHouse, but end users must continue working with the standard +Odoo Audit Log screens. + +Approach: + +The module keeps the usual Odoo audit log interface while changing the +read source behind it. Instead of reading audit log data from local +PostgreSQL audit tables, Odoo can read it through PostgreSQL foreign +tables backed by ClickHouse. + +This allows administrators to keep the familiar menus, forms, filters, +and grouping options while using ClickHouse as the effective source for +audit log reads. + +Useful information: + +The module is especially useful in databases with high audit log volumes +or in setups where audit data should be stored outside the main +PostgreSQL audit tables. + +When FDW read mode is enabled, audit log records are read-only in Odoo. +If needed, administrators can disable FDW read mode and restore reading +from the local PostgreSQL audit log tables. + +Configuration +============= + +To configure this module, you need to: + +1. Make sure the PostgreSQL extension ``pg_clickhouse`` is installed + and available on the PostgreSQL server used by Odoo. + +2. Make sure the ClickHouse database is reachable from the Odoo server. + +3. Make sure the audit log tables already exist in ClickHouse. + +4. Activate developer mode in Odoo. + +5. Go to *Settings > Technical > Auditlog > ClickHouse Configurations*. + +6. Open the active ClickHouse configuration used for audit log export. + +7. Fill in or verify the connection parameters: + + - *Hostname or IP* + - *TCP Port* + - *Database name* + - *User* + - *Password* + +8. Use *Test Connection* to verify that Odoo can connect to ClickHouse. + +9. Use *Create Auditlog Tables* if the ClickHouse audit log tables have + not yet been created. + +10. Click *Enable FDW read* to switch standard Odoo audit log views to + ClickHouse-backed foreign tables. + +Important notes: + +- Only the active ClickHouse configuration can enable FDW read. +- The PostgreSQL user used by Odoo must have the required privileges to + create and use FDW objects. +- While FDW read is enabled, the active ClickHouse configuration cannot + be deactivated, deleted, or changed for connection-related fields + until FDW read is disabled. + +Usage +===== + +To use this module, you need to: + +1. Go to *Settings > Technical > Auditlog > ClickHouse Configurations*. + +2. Open the active ClickHouse configuration. + +3. Click *Enable FDW read*. + +4. Open the standard audit log menus in Odoo: + + - *Settings > Technical > Audit > Logs* + +5. Review audit log records as usual from the standard Odoo interface. + +6. Use the existing search, filters, and group by options in audit log + views to analyze audit data stored in ClickHouse. + +7. Open an audited record and use the standard *View Logs* action when + available. The action continues to open the related audit log entries + through the standard Odoo interface. + +Important notes: + +- While FDW read mode is enabled, audit log records are read-only in + Odoo. +- To return to local PostgreSQL audit log tables, go back to the active + ClickHouse configuration and click *Disable FDW read*. + +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 +------- + +* Cetmix + +Contributors +------------ + +- `Cetmix `__ + + - Ivan Sokolov + - George Smirnov + - Dmitry Meita + +Other credits +------------- + +The development of this module has been financially supported by: + +- Geschäftsstelle Sozialinfo + +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/server-tools `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/auditlog_clickhouse_read/__init__.py b/auditlog_clickhouse_read/__init__.py new file mode 100644 index 00000000000..0bec10b12c2 --- /dev/null +++ b/auditlog_clickhouse_read/__init__.py @@ -0,0 +1,4 @@ +# Copyright (C) 2026 Cetmix OÜ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import models diff --git a/auditlog_clickhouse_read/__manifest__.py b/auditlog_clickhouse_read/__manifest__.py new file mode 100644 index 00000000000..35469060a72 --- /dev/null +++ b/auditlog_clickhouse_read/__manifest__.py @@ -0,0 +1,18 @@ +# Copyright (C) 2026 Cetmix OÜ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Read auditlog records stored in clickhouse.", + "version": "18.0.1.0.0", + "summary": "Read auditlog from clickhouse using FDW", + "category": "Tools", + "license": "AGPL-3", + "author": "Odoo Community Association (OCA), Cetmix", + "website": "https://github.com/OCA/server-tools", + "depends": [ + "auditlog_clickhouse_write", + ], + "data": [ + "views/auditlog_clickhouse_config_views.xml", + ], +} diff --git a/auditlog_clickhouse_read/i18n/auditlog_clickhouse_read.pot b/auditlog_clickhouse_read/i18n/auditlog_clickhouse_read.pot new file mode 100644 index 00000000000..2236cefca5e --- /dev/null +++ b/auditlog_clickhouse_read/i18n/auditlog_clickhouse_read.pot @@ -0,0 +1,223 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * auditlog_clickhouse_read +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-03-09 19:27+0000\n" +"PO-Revision-Date: 2026-03-09 19:27+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: auditlog_clickhouse_read +#. odoo-python +#: code:addons/auditlog_clickhouse_read/models/auditlog_clickhouse_config.py:0 +msgid "" +"%(message)s.\n" +"\n" +"The current PostgreSQL user does not have enough privileges.\n" +"Ask your DBA to grant:\n" +"- USAGE on foreign-data wrapper clickhouse_fdw;\n" +"- USAGE on foreign server %(server)s;\n" +"- CREATE and USAGE on schema %(schema)s.\n" +"\n" +"Original error: %(error)s" +msgstr "" + +#. module: auditlog_clickhouse_read +#. odoo-python +#: code:addons/auditlog_clickhouse_read/models/auditlog_clickhouse_config.py:0 +msgid "%(message)s: %(error)s" +msgstr "" + +#. module: auditlog_clickhouse_read +#. odoo-python +#: code:addons/auditlog_clickhouse_read/models/auditlog_clickhouse_config.py:0 +msgid "" +"Another active ClickHouse configuration already has FDW read enabled: " +"%(config)s. Disable FDW read there first." +msgstr "" + +#. module: auditlog_clickhouse_read +#. odoo-python +#: code:addons/auditlog_clickhouse_read/models/auditlog_readonly.py:0 +msgid "Audit logs are read-only while FDW read mode is enabled." +msgstr "" + +#. module: auditlog_clickhouse_read +#: model:ir.model,name:auditlog_clickhouse_read.model_auditlog_log +msgid "Auditlog - Log" +msgstr "" + +#. module: auditlog_clickhouse_read +#: model:ir.model,name:auditlog_clickhouse_read.model_auditlog_log_line +msgid "Auditlog - Log details (fields updated)" +msgstr "" + +#. module: auditlog_clickhouse_read +#: model:ir.model,name:auditlog_clickhouse_read.model_auditlog_clickhouse_config +msgid "Auditlog ClickHouse Write Configuration" +msgstr "" + +#. module: auditlog_clickhouse_read +#. odoo-python +#: code:addons/auditlog_clickhouse_read/models/auditlog_clickhouse_config.py:0 +msgid "" +"Auditlog read mode is in an inconsistent PostgreSQL state.\n" +"\n" +"%(log_table)s: %(log_state)s\n" +"%(line_table)s: %(line_state)s\n" +"%(log_backup)s: %(log_backup_state)s\n" +"%(line_backup)s: %(line_backup_state)s\n" +"\n" +"Fix the schema state manually or restore a consistent mode before trying again." +msgstr "" + +#. module: auditlog_clickhouse_read +#. odoo-python +#: code:addons/auditlog_clickhouse_read/models/auditlog_clickhouse_config.py:0 +msgid "" +"Cannot disable FDW read because PostgreSQL backup tables are missing. " +"Partial rollback is not allowed." +msgstr "" + +#. module: auditlog_clickhouse_read +#. odoo-python +#: code:addons/auditlog_clickhouse_read/models/auditlog_clickhouse_config.py:0 +msgid "" +"Cannot enable FDW read because backup auditlog tables already exist. Clean " +"the stale backup objects first." +msgstr "" + +#. module: auditlog_clickhouse_read +#: model_terms:ir.ui.view,arch_db:auditlog_clickhouse_read.view_auditlog_clickhouse_config_form_read +msgid "Disable FDW read" +msgstr "" + +#. module: auditlog_clickhouse_read +#: model_terms:ir.ui.view,arch_db:auditlog_clickhouse_read.view_auditlog_clickhouse_config_form_read +msgid "Enable FDW read" +msgstr "" + +#. module: auditlog_clickhouse_read +#. odoo-python +#: code:addons/auditlog_clickhouse_read/models/auditlog_clickhouse_config.py:0 +msgid "" +"FDW read activation failed because auditlog data cannot be read through " +"PostgreSQL FDW: %(error)s" +msgstr "" + +#. module: auditlog_clickhouse_read +#: model:ir.model.fields,field_description:auditlog_clickhouse_read.field_auditlog_clickhouse_config__fdw_enabled +msgid "FDW read enabled" +msgstr "" + +#. module: auditlog_clickhouse_read +#. odoo-python +#: code:addons/auditlog_clickhouse_read/models/auditlog_clickhouse_config.py:0 +msgid "FDW read is already disabled." +msgstr "" + +#. module: auditlog_clickhouse_read +#. odoo-python +#: code:addons/auditlog_clickhouse_read/models/auditlog_clickhouse_config.py:0 +msgid "FDW read is already enabled." +msgstr "" + +#. module: auditlog_clickhouse_read +#. odoo-python +#: code:addons/auditlog_clickhouse_read/models/auditlog_clickhouse_config.py:0 +msgid "FDW read is disabled and PostgreSQL tables were restored." +msgstr "" + +#. module: auditlog_clickhouse_read +#. odoo-python +#: code:addons/auditlog_clickhouse_read/models/auditlog_clickhouse_config.py:0 +msgid "FDW read is enabled for auditlog." +msgstr "" + +#. module: auditlog_clickhouse_read +#. odoo-python +#: code:addons/auditlog_clickhouse_read/models/auditlog_clickhouse_config.py:0 +msgid "Failed to create or update the FDW server" +msgstr "" + +#. module: auditlog_clickhouse_read +#. odoo-python +#: code:addons/auditlog_clickhouse_read/models/auditlog_clickhouse_config.py:0 +msgid "Failed to create or update the FDW user mapping" +msgstr "" + +#. module: auditlog_clickhouse_read +#. odoo-python +#: code:addons/auditlog_clickhouse_read/models/auditlog_clickhouse_config.py:0 +msgid "Failed to initialize pg_clickhouse extension" +msgstr "" + +#. module: auditlog_clickhouse_read +#. odoo-python +#: code:addons/auditlog_clickhouse_read/models/auditlog_clickhouse_config.py:0 +msgid "Host is required." +msgstr "" + +#. module: auditlog_clickhouse_read +#. odoo-python +#: code:addons/auditlog_clickhouse_read/models/auditlog_clickhouse_config.py:0 +msgid "Nothing to do" +msgstr "" + +#. module: auditlog_clickhouse_read +#. odoo-python +#: code:addons/auditlog_clickhouse_read/models/auditlog_clickhouse_config.py:0 +msgid "Only the active ClickHouse configuration can enable FDW read." +msgstr "" + +#. module: auditlog_clickhouse_read +#. odoo-python +#: code:addons/auditlog_clickhouse_read/models/auditlog_clickhouse_config.py:0 +msgid "Success" +msgstr "" + +#. module: auditlog_clickhouse_read +#: model:ir.model.fields,help:auditlog_clickhouse_read.field_auditlog_clickhouse_config__fdw_enabled +msgid "" +"Technical flag showing whether auditlog is currently read through PostgreSQL" +" foreign tables backed by ClickHouse." +msgstr "" + +#. module: auditlog_clickhouse_read +#: model_terms:ir.ui.view,arch_db:auditlog_clickhouse_read.view_auditlog_clickhouse_config_form_read +msgid "" +"This will restore PostgreSQL auditlog tables and disable FDW read mode. " +"Continue?" +msgstr "" + +#. module: auditlog_clickhouse_read +#. odoo-python +#: code:addons/auditlog_clickhouse_read/models/auditlog_clickhouse_config.py:0 +msgid "" +"You cannot change ClickHouse connection parameters while FDW read is " +"enabled. Disable FDW read first." +msgstr "" + +#. module: auditlog_clickhouse_read +#. odoo-python +#: code:addons/auditlog_clickhouse_read/models/auditlog_clickhouse_config.py:0 +msgid "" +"You cannot deactivate a ClickHouse configuration while FDW read is enabled. " +"Disable FDW read first." +msgstr "" + +#. module: auditlog_clickhouse_read +#. odoo-python +#: code:addons/auditlog_clickhouse_read/models/auditlog_clickhouse_config.py:0 +msgid "" +"You cannot delete a ClickHouse configuration while FDW read is enabled. " +"Disable FDW read first." +msgstr "" diff --git a/auditlog_clickhouse_read/models/__init__.py b/auditlog_clickhouse_read/models/__init__.py new file mode 100644 index 00000000000..2c0b6bfab89 --- /dev/null +++ b/auditlog_clickhouse_read/models/__init__.py @@ -0,0 +1,5 @@ +# Copyright (C) 2026 Cetmix OÜ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import auditlog_clickhouse_config +from . import auditlog_readonly diff --git a/auditlog_clickhouse_read/models/auditlog_clickhouse_config.py b/auditlog_clickhouse_read/models/auditlog_clickhouse_config.py new file mode 100644 index 00000000000..cd11c219176 --- /dev/null +++ b/auditlog_clickhouse_read/models/auditlog_clickhouse_config.py @@ -0,0 +1,811 @@ +# Copyright (C) 2026 Cetmix OÜ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging + +from odoo import api, fields, models +from odoo.exceptions import UserError +from odoo.tools import SQL + +_logger = logging.getLogger(__name__) + + +class AuditlogClickhouseConfig(models.Model): + """Configure auditlog read mode through PostgreSQL FDW. + + This extension adds a switchable read mode on top of the active + ClickHouse write configuration: + + - FDW read OFF -> Odoo reads regular PostgreSQL auditlog tables. + - FDW read ON -> Odoo reads ClickHouse through FOREIGN TABLE objects. + + The write pipeline remains controlled by auditlog_clickhouse_write. + This model only manages runtime DDL required for reading. + """ + + _inherit = "auditlog.clickhouse.config" + + AUDITLOG_SCHEMA = "public" + LOG_TABLE = "auditlog_log" + FDW_SERVER = "auditlog_clickhouse_srv" + LOG_LINE_TABLE = "auditlog_log_line" + LOG_TABLE_BACKUP = "auditlog_log_pg_backup" + LOG_LINE_TABLE_BACKUP = "auditlog_log_line_pg_backup" + LOG_LINE_VIEW = "auditlog_log_line_view" + + _FDW_LOCKED_FIELDS = {"host", "port", "database", "user", "password"} + + fdw_enabled = fields.Boolean( + string="FDW read enabled", + readonly=True, + help=( + "Technical flag showing whether auditlog is currently read through " + "PostgreSQL foreign tables backed by ClickHouse." + ), + ) + + def write(self, vals): + """Validate FDW-related safeguards before updating configuration. + + Blocks configuration changes that would leave auditlog read mode in an + inconsistent or unsupported state. + + :param dict vals: Values to write on the configuration recordset. + :return: Result of the parent ``write`` call. + :rtype: bool + :raises UserError: If the requested update is forbidden while FDW read + is enabled. + """ + self._check_fdw_write_constraints(vals) + return super().write(vals) + + def unlink(self): + """Prevent deleting configuration records while FDW read is enabled. + + :return: Result of the parent ``unlink`` call. + :rtype: bool + :raises UserError: If any record in ``self`` has ``fdw_enabled=True``. + """ + if self.filtered("fdw_enabled"): + raise UserError( + self.env._( + "You cannot delete a ClickHouse configuration while FDW read " + "is enabled. Disable FDW read first." + ) + ) + return super().unlink() + + def _check_fdw_write_constraints(self, vals): + """Validate configuration changes that may break active FDW read mode. + + The method protects the following cases: + + - deactivating a configuration while FDW read is enabled; + - activating another configuration while some other active configuration + already has FDW read enabled; + - changing connection parameters while FDW read is enabled. + + :param dict vals: Values passed to ``write()``. + :raises UserError: If the requested modification is not allowed. + """ + if vals.get("is_active") is False and self.filtered("fdw_enabled"): + raise UserError( + self.env._( + "You cannot deactivate a ClickHouse configuration while FDW " + "read is enabled. Disable FDW read first." + ) + ) + + if vals.get("is_active") is True: + other_fdw_config = self.search( + [ + ("id", "not in", self.ids), + ("is_active", "=", True), + ("fdw_enabled", "=", True), + ], + limit=1, + ) + if other_fdw_config: + raise UserError( + self.env._( + "Another active ClickHouse configuration already has FDW " + "read enabled: %(config)s. Disable FDW read there first.", + config=other_fdw_config.display_name, + ) + ) + + if self._FDW_LOCKED_FIELDS.intersection(vals) and self.filtered("fdw_enabled"): + raise UserError( + self.env._( + "You cannot change ClickHouse connection parameters while FDW " + "read is enabled. Disable FDW read first." + ) + ) + + @api.model + def _relation_kind(self, schema, name): + """Return PostgreSQL relation kind for a fully qualified object name. + + The value comes from ``pg_class.relkind`` and is used to determine + whether a relation is a regular table, foreign table, view, or missing. + + :param str schema: PostgreSQL schema name. + :param str name: Relation name inside the schema. + :return: Relation kind code, or ``None`` if the relation does not exist. + :rtype: str | None + """ + self.env.cr.execute("SELECT to_regclass(%s)", (f"{schema}.{name}",)) + row = self.env.cr.fetchone() + regclass_name = row[0] if row else None + if not regclass_name: + return None + + self.env.cr.execute( + """ + SELECT c.relkind + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = %s + AND c.relname = %s + """, + (schema, name), + ) + row = self.env.cr.fetchone() + return row[0] if row else None + + @api.model + def _describe_relation_kind(self, kind): + """Return a human-readable label for a PostgreSQL relation kind. + + :param str | None kind: Value of ``pg_class.relkind`` or ``None``. + :return: Human-readable relation type label. + :rtype: str + """ + labels = { + None: "missing", + "r": "regular table", + "f": "foreign table", + "v": "view", + } + return labels.get(kind, f"unexpected relation ({kind})") + + @api.model + def _get_auditlog_read_mode(self): + """Detect the current auditlog read mode from PostgreSQL relations. + + Mode detection is based on relation kinds of + ``auditlog_log`` and ``auditlog_log_line``: + + - ``"fdw"`` when both are foreign tables; + - ``"postgres"`` when both are regular tables; + - ``"mixed"`` for any inconsistent combination. + + :return: Current auditlog read mode. + :rtype: str + """ + schema = self.AUDITLOG_SCHEMA + log_kind = self._relation_kind(schema, self.LOG_TABLE) + line_kind = self._relation_kind(schema, self.LOG_LINE_TABLE) + + if log_kind == "f" and line_kind == "f": + return "fdw" + if log_kind == "r" and line_kind == "r": + return "postgres" + return "mixed" + + @api.model + def _backup_tables_exist(self): + """Check whether both PostgreSQL auditlog backup tables exist. + + :return: ``True`` if both backup tables are present as regular tables. + :rtype: bool + """ + schema = self.AUDITLOG_SCHEMA + return ( + self._relation_kind(schema, self.LOG_TABLE_BACKUP) == "r" + and self._relation_kind(schema, self.LOG_LINE_TABLE_BACKUP) == "r" + ) + + @api.model + def _any_backup_object_exists(self): + """Check whether any auditlog backup relation already exists. + + This helper is used before enabling FDW read to avoid colliding with + stale backup objects left from an interrupted or manual operation. + + :return: ``True`` if at least one backup relation exists. + :rtype: bool + """ + schema = self.AUDITLOG_SCHEMA + return bool( + self._relation_kind(schema, self.LOG_TABLE_BACKUP) + or self._relation_kind(schema, self.LOG_LINE_TABLE_BACKUP) + ) + + @api.model + def _raise_inconsistent_schema_state(self): + """Raise a detailed error for a mixed or corrupted auditlog schema state. + + The message contains the detected state of main and backup relations to + help the operator understand what must be fixed before continuing. + + :raises UserError: Always, with details about current PostgreSQL objects. + """ + schema = self.AUDITLOG_SCHEMA + state = { + self.LOG_TABLE: self._describe_relation_kind( + self._relation_kind(schema, self.LOG_TABLE) + ), + self.LOG_LINE_TABLE: self._describe_relation_kind( + self._relation_kind(schema, self.LOG_LINE_TABLE) + ), + self.LOG_TABLE_BACKUP: self._describe_relation_kind( + self._relation_kind(schema, self.LOG_TABLE_BACKUP) + ), + self.LOG_LINE_TABLE_BACKUP: self._describe_relation_kind( + self._relation_kind(schema, self.LOG_LINE_TABLE_BACKUP) + ), + } + raise UserError( + self.env._( + "Auditlog read mode is in an inconsistent PostgreSQL state.\n\n" + "%(log_table)s: %(log_state)s\n" + "%(line_table)s: %(line_state)s\n" + "%(log_backup)s: %(log_backup_state)s\n" + "%(line_backup)s: %(line_backup_state)s\n\n" + "Fix the schema state manually or restore a consistent mode " + "before trying again.", + log_table=self.LOG_TABLE, + log_state=state[self.LOG_TABLE], + line_table=self.LOG_LINE_TABLE, + line_state=state[self.LOG_LINE_TABLE], + log_backup=self.LOG_TABLE_BACKUP, + log_backup_state=state[self.LOG_TABLE_BACKUP], + line_backup=self.LOG_LINE_TABLE_BACKUP, + line_backup_state=state[self.LOG_LINE_TABLE_BACKUP], + ) + ) + + def action_enable_fdw_read(self): + """Enable FDW-based auditlog reading from ClickHouse. + + The method validates the current state, prepares FDW objects, swaps + local PostgreSQL tables with foreign tables, performs a healthcheck, + and finally synchronizes the technical ``fdw_enabled`` flag. + + :return: Standard client notification action. + :rtype: dict + :raises UserError: If the configuration is not active, the schema state + is inconsistent, stale backup objects exist, or FDW setup fails. + """ + self.ensure_one() + + if not self.is_active: + raise UserError( + self.env._( + "Only the active ClickHouse configuration can enable FDW read." + ) + ) + + read_mode = self._get_auditlog_read_mode() + if read_mode == "fdw": + self._set_fdw_enabled_flag(True) + return self._notify( + title=self.env._("Nothing to do"), + message=self.env._("FDW read is already enabled."), + notif_type="info", + ) + if read_mode == "mixed": + self._raise_inconsistent_schema_state() + + if self._any_backup_object_exists(): + raise UserError( + self.env._( + "Cannot enable FDW read because backup auditlog tables already " + "exist. Clean the stale backup objects first." + ) + ) + + self._ensure_pg_clickhouse_extension() + self._create_or_update_fdw_server() + self._create_or_update_fdw_user_mapping() + self._swap_auditlog_tables_to_fdw() + self._healthcheck_fdw_read() + + self._set_fdw_enabled_flag(True) + _logger.info("auditlog_clickhouse_read: FDW read enabled (config=%s)", self.id) + return self._notify( + title=self.env._("Success"), + message=self.env._("FDW read is enabled for auditlog."), + notif_type="success", + ) + + def action_disable_fdw_read(self): + """Disable FDW-based auditlog reading and restore PostgreSQL tables. + + The method validates the current mode, restores local backup tables, + recreates the SQL view, and synchronizes the technical flag. + + :return: Standard client notification action. + :rtype: dict + :raises UserError: If the schema state is inconsistent or required + PostgreSQL backup tables are missing. + """ + self.ensure_one() + + read_mode = self._get_auditlog_read_mode() + if read_mode == "postgres": + self._set_fdw_enabled_flag(False) + return self._notify( + title=self.env._("Nothing to do"), + message=self.env._("FDW read is already disabled."), + notif_type="info", + ) + if read_mode == "mixed": + self._raise_inconsistent_schema_state() + + if not self._backup_tables_exist(): + raise UserError( + self.env._( + "Cannot disable FDW read because PostgreSQL backup tables are " + "missing. Partial rollback is not allowed." + ) + ) + + self._restore_auditlog_tables_from_backup() + self._set_fdw_enabled_flag(False) + _logger.info("auditlog_clickhouse_read: FDW read disabled (config=%s)", self.id) + return self._notify( + title=self.env._("Success"), + message=self.env._( + "FDW read is disabled and PostgreSQL tables were restored." + ), + notif_type="success", + ) + + def _set_fdw_enabled_flag(self, enabled): + """Synchronize the technical FDW flag with the actual schema state. + + :param bool enabled: Target value for ``fdw_enabled``. + :return: ``None`` + :rtype: None + """ + if self.fdw_enabled == enabled: + return + super().write({"fdw_enabled": enabled}) + + def _ensure_pg_clickhouse_extension(self): + """Ensure the ``pg_clickhouse`` extension exists in PostgreSQL. + + :raises UserError: If PostgreSQL fails to create or load the extension. + """ + try: + self.env.cr.execute("CREATE EXTENSION IF NOT EXISTS pg_clickhouse") + except Exception as exc: + self._raise_fdw_setup_error( + self.env._("Failed to initialize pg_clickhouse extension"), + exc, + ) + + def _create_or_update_fdw_server(self): + """Create or update the PostgreSQL foreign server definition. + + The server points PostgreSQL FDW reads to the configured ClickHouse + instance and database. + + :raises UserError: If host is missing or the server DDL fails. + """ + driver = "binary" + host = (self.host or "").strip() + if not host: + raise UserError(self.env._("Host is required.")) + + port = str(int(self.port or 0) or self.DEFAULT_PORT) + dbname = (self.database or "").strip() or self.DEFAULT_DB + + try: + if self._fdw_server_exists(): + self.env.cr.execute( + SQL( + """ + ALTER SERVER %s OPTIONS ( + SET driver %s, + SET host %s, + SET port %s, + SET dbname %s + ) + """, + SQL.identifier(self.FDW_SERVER), + driver, + host, + port, + dbname, + ) + ) + else: + self.env.cr.execute( + SQL( + """ + CREATE SERVER %s + FOREIGN DATA WRAPPER clickhouse_fdw + OPTIONS ( + driver %s, + host %s, + port %s, + dbname %s + ) + """, + SQL.identifier(self.FDW_SERVER), + driver, + host, + port, + dbname, + ) + ) + except Exception as exc: + self._raise_fdw_setup_error( + self.env._("Failed to create or update the FDW server"), + exc, + ) + + def _create_or_update_fdw_user_mapping(self): + """Create or update the user mapping for the current PostgreSQL user. + + The mapping stores ClickHouse credentials used by PostgreSQL FDW when + querying foreign tables. + + :raises UserError: If PostgreSQL fails to create or update the mapping. + """ + ch_user = (self.user or "default").strip() or "default" + ch_password = self.password or "" + + try: + if self._fdw_user_mapping_exists(): + self.env.cr.execute( + SQL( + """ + ALTER USER MAPPING FOR CURRENT_USER + SERVER %s + OPTIONS ( + SET user %s, + SET password %s + ) + """, + SQL.identifier(self.FDW_SERVER), + ch_user, + ch_password, + ) + ) + else: + self.env.cr.execute( + SQL( + """ + CREATE USER MAPPING FOR CURRENT_USER + SERVER %s + OPTIONS ( + user %s, + password %s + ) + """, + SQL.identifier(self.FDW_SERVER), + ch_user, + ch_password, + ) + ) + except Exception as exc: + self._raise_fdw_setup_error( + self.env._("Failed to create or update the FDW user mapping"), + exc, + ) + + def _raise_fdw_setup_error(self, message, exc): + """Raise a normalized user-facing error for FDW setup failures. + + For PostgreSQL privilege errors, the method returns a more explicit DBA + instruction. Other errors are wrapped into a generic ``UserError``. + + :param str message: Human-readable operation context. + :param Exception exc: Original exception raised during FDW setup. + :raises UserError: Always, with a normalized message for the UI. + """ + if getattr(exc, "pgcode", None) == "42501": + raise UserError( + self.env._( + "%(message)s.\n\n" + "The current PostgreSQL user does not have enough privileges.\n" + "Ask your DBA to grant:\n" + "- USAGE on foreign-data wrapper clickhouse_fdw;\n" + "- USAGE on foreign server %(server)s;\n" + "- CREATE and USAGE on schema %(schema)s.\n\n" + "Original error: %(error)s", + message=message, + server=self.FDW_SERVER, + schema=self.AUDITLOG_SCHEMA, + error=str(exc), + ) + ) from exc + + raise UserError( + self.env._("%(message)s: %(error)s", message=message, error=str(exc)) + ) from exc + + @api.model + def _fdw_server_exists(self): + """Check whether the PostgreSQL foreign server already exists. + + :return: ``True`` if the configured foreign server exists. + :rtype: bool + """ + self.env.cr.execute( + "SELECT 1 FROM pg_foreign_server WHERE srvname = %s", + (self.FDW_SERVER,), + ) + return bool(self.env.cr.fetchone()) + + @api.model + def _fdw_user_mapping_exists(self): + """Check whether a user mapping exists for ``CURRENT_USER``. + + :return: ``True`` if a mapping exists for the configured FDW server. + :rtype: bool + """ + self.env.cr.execute( + "SELECT 1 FROM pg_user_mappings " + "WHERE srvname = %s AND usename = current_user", + (self.FDW_SERVER,), + ) + return bool(self.env.cr.fetchone()) + + @api.model + def _drop_foreign_table_if_exists(self, schema, name): + """Drop a foreign table when the target relation is an FDW object. + + :param str schema: PostgreSQL schema name. + :param str name: Relation name to drop. + """ + if self._relation_kind(schema, name) == "f": + self.env.cr.execute( + SQL( + "DROP FOREIGN TABLE %s.%s", + SQL.identifier(schema), + SQL.identifier(name), + ) + ) + + @api.model + def _rename_table_if_exists(self, schema, name, new_name): + """Rename a regular PostgreSQL table when it exists. + + :param str schema: PostgreSQL schema name. + :param str name: Current table name. + :param str new_name: Target table name. + """ + if self._relation_kind(schema, name) == "r": + self.env.cr.execute( + SQL( + "ALTER TABLE %s.%s RENAME TO %s", + SQL.identifier(schema), + SQL.identifier(name), + SQL.identifier(new_name), + ) + ) + + @api.model + def _drop_view_if_exists(self, schema, name): + """Drop an SQL view if it exists. + + :param str schema: PostgreSQL schema name. + :param str name: View name. + """ + self.env.cr.execute( + SQL( + "DROP VIEW IF EXISTS %s.%s", + SQL.identifier(schema), + SQL.identifier(name), + ) + ) + + @api.model + def _ensure_sequences(self): + """Ensure auditlog sequences exist for the write pipeline. + + These sequences are required because auditlog rows written to + ClickHouse still depend on PostgreSQL-generated integer identifiers. + """ + self.env.cr.execute("CREATE SEQUENCE IF NOT EXISTS auditlog_log_id_seq") + self.env.cr.execute("CREATE SEQUENCE IF NOT EXISTS auditlog_log_line_id_seq") + + def _create_foreign_tables(self, schema): + """Create PostgreSQL foreign tables for auditlog data stored in ClickHouse. + + The created table schemas must match ORM expectations for + ``auditlog.log`` and ``auditlog.log.line``. + + :param str schema: PostgreSQL schema where foreign tables must be created. + """ + db_opt = (self.database or "").strip() or self.DEFAULT_DB + + self.env.cr.execute( + SQL( + """ + CREATE FOREIGN TABLE %s.%s ( + id bigint, + create_date timestamp, + create_uid integer, + write_date timestamp, + write_uid integer, + name text, + model_id integer, + model_name text, + model_model text, + res_id bigint, + res_ids text, + user_id integer, + method text, + http_session_id integer, + http_request_id integer, + log_type text + ) + SERVER %s + OPTIONS (table_name %s, database %s) + """, + SQL.identifier(schema), + SQL.identifier(self.LOG_TABLE), + SQL.identifier(self.FDW_SERVER), + self.LOG_TABLE, + db_opt, + ) + ) + + self.env.cr.execute( + SQL( + """ + CREATE FOREIGN TABLE %s.%s ( + id bigint, + create_date timestamp, + create_uid integer, + write_date timestamp, + write_uid integer, + field_id integer, + log_id bigint, + old_value text, + new_value text, + old_value_text text, + new_value_text text, + field_name text, + field_description text + ) + SERVER %s + OPTIONS (table_name %s, database %s) + """, + SQL.identifier(schema), + SQL.identifier(self.LOG_LINE_TABLE), + SQL.identifier(self.FDW_SERVER), + self.LOG_LINE_TABLE, + db_opt, + ) + ) + + @api.model + def _recreate_auditlog_log_line_view(self, schema): + """Recreate the SQL view used by ``auditlog.log.line.view``. + + The view is rebuilt against whichever auditlog relations are currently + active: regular PostgreSQL tables or FDW foreign tables. + + :param str schema: PostgreSQL schema where the SQL view must be created. + """ + self._drop_view_if_exists(schema, self.LOG_LINE_VIEW) + self.env.cr.execute( + SQL( + """ + CREATE VIEW %s.%s AS + SELECT alogl.id, + alogl.create_date, + alogl.create_uid, + alogl.write_uid, + alogl.write_date, + alogl.field_id, + alogl.log_id, + alogl.old_value, + alogl.new_value, + alogl.old_value_text, + alogl.new_value_text, + alogl.field_name, + alogl.field_description, + alog.name, + alog.model_id, + alog.model_name, + alog.model_model, + alog.res_id, + alog.user_id, + alog.method, + alog.http_session_id, + alog.http_request_id, + alog.log_type + FROM %s.%s alogl + JOIN %s.%s alog ON alog.id = alogl.log_id + """, + SQL.identifier(schema), + SQL.identifier(self.LOG_LINE_VIEW), + SQL.identifier(schema), + SQL.identifier(self.LOG_LINE_TABLE), + SQL.identifier(schema), + SQL.identifier(self.LOG_TABLE), + ) + ) + + def _swap_auditlog_tables_to_fdw(self): + """Replace local auditlog tables with FDW-backed foreign tables. + + The method preserves original PostgreSQL data by renaming the tables to + backup names before creating foreign tables under the original names. + """ + schema = self.AUDITLOG_SCHEMA + self._drop_view_if_exists(schema, self.LOG_LINE_VIEW) + self._drop_foreign_table_if_exists(schema, self.LOG_LINE_TABLE) + self._drop_foreign_table_if_exists(schema, self.LOG_TABLE) + self._rename_table_if_exists( + schema, + self.LOG_LINE_TABLE, + self.LOG_LINE_TABLE_BACKUP, + ) + self._rename_table_if_exists( + schema, + self.LOG_TABLE, + self.LOG_TABLE_BACKUP, + ) + self._ensure_sequences() + self._create_foreign_tables(schema) + self._recreate_auditlog_log_line_view(schema) + + @api.model + def _restore_auditlog_tables_from_backup(self): + """Restore original PostgreSQL auditlog tables from backup names. + + The method drops active foreign tables, renames backup tables back to + their original names, and recreates the SQL view. + """ + schema = self.AUDITLOG_SCHEMA + self._drop_view_if_exists(schema, self.LOG_LINE_VIEW) + self._drop_foreign_table_if_exists(schema, self.LOG_LINE_TABLE) + self._drop_foreign_table_if_exists(schema, self.LOG_TABLE) + self._rename_table_if_exists( + schema, + self.LOG_LINE_TABLE_BACKUP, + self.LOG_LINE_TABLE, + ) + self._rename_table_if_exists( + schema, + self.LOG_TABLE_BACKUP, + self.LOG_TABLE, + ) + self._recreate_auditlog_log_line_view(schema) + + @api.model + def _healthcheck_fdw_read(self): + """Verify that auditlog foreign tables are readable after FDW activation. + + The healthcheck performs a lightweight read against the active + ``auditlog_log`` relation to ensure that PostgreSQL FDW and ClickHouse + are reachable for auditlog UI reads. + + :raises UserError: If the foreign table cannot be queried. + """ + try: + self.env.cr.execute( + SQL( + "SELECT 1 FROM %s.%s LIMIT 1", + SQL.identifier(self.AUDITLOG_SCHEMA), + SQL.identifier(self.LOG_TABLE), + ) + ) + self.env.cr.fetchone() + except Exception as exc: + raise UserError( + self.env._( + "FDW read activation failed because auditlog data cannot be " + "read through PostgreSQL FDW: %(error)s", + error=str(exc), + ) + ) from exc diff --git a/auditlog_clickhouse_read/models/auditlog_readonly.py b/auditlog_clickhouse_read/models/auditlog_readonly.py new file mode 100644 index 00000000000..9ce2c4e6f88 --- /dev/null +++ b/auditlog_clickhouse_read/models/auditlog_readonly.py @@ -0,0 +1,115 @@ +# Copyright (C) 2026 Cetmix OÜ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, models +from odoo.exceptions import UserError + + +def _is_clickhouse_readonly_mode(env): + """Check whether auditlog is currently in ClickHouse-backed read-only mode. + + Read-only mode is considered enabled when auditlog records are read through + PostgreSQL FDW relations backed by ClickHouse. + + :param odoo.api.Environment env: Current Odoo environment. + :return: ``True`` if auditlog read mode is ``"fdw"``, otherwise ``False``. + :rtype: bool + """ + return env["auditlog.clickhouse.config"].sudo()._get_auditlog_read_mode() == "fdw" + + +def _raise_clickhouse_readonly(env): + """Raise a user-facing error for auditlog read-only mode. + + This helper is used to block any write operation on auditlog models while + ClickHouse FDW read mode is enabled. + + :param odoo.api.Environment env: Current Odoo environment. + :raises UserError: Always, with a message explaining that audit logs are + read-only while FDW read mode is enabled. + """ + raise UserError(env._("Audit logs are read-only while FDW read mode is enabled.")) + + +class AuditlogLogReadonly(models.Model): + """Prevent modifications of ``auditlog.log`` in FDW read mode.""" + + _inherit = "auditlog.log" + + @api.model_create_multi + def create(self, vals_list): + """Block creation of auditlog log records in FDW read mode. + + :param list[dict] vals_list: Values for records to create. + :return: Created records from the parent implementation. + :rtype: odoo.models.Model + :raises UserError: If auditlog is currently in ClickHouse read-only mode. + """ + if _is_clickhouse_readonly_mode(self.env): + _raise_clickhouse_readonly(self.env) + return super().create(vals_list) + + def write(self, vals): + """Block updates of auditlog log records in FDW read mode. + + :param dict vals: Values to write on the recordset. + :return: Result of the parent ``write`` call. + :rtype: bool + :raises UserError: If auditlog is currently in ClickHouse read-only mode. + """ + if _is_clickhouse_readonly_mode(self.env): + _raise_clickhouse_readonly(self.env) + return super().write(vals) + + def unlink(self): + """Block deletion of auditlog log records in FDW read mode. + + :return: Result of the parent ``unlink`` call. + :rtype: bool + :raises UserError: If auditlog is currently in ClickHouse read-only mode. + """ + if _is_clickhouse_readonly_mode(self.env): + _raise_clickhouse_readonly(self.env) + return super().unlink() + + +class AuditlogLogLineReadonly(models.Model): + """Prevent modifications of ``auditlog.log.line`` in FDW read mode.""" + + _inherit = "auditlog.log.line" + + @api.model_create_multi + def create(self, vals_list): + """Block creation of auditlog log line records in FDW read mode. + + :param list[dict] vals_list: Values for records to create. + :return: Created records from the parent implementation. + :rtype: odoo.models.Model + :raises UserError: If auditlog is currently in ClickHouse read-only mode. + """ + if _is_clickhouse_readonly_mode(self.env): + _raise_clickhouse_readonly(self.env) + return super().create(vals_list) + + def write(self, vals): + """Block updates of auditlog log line records in FDW read mode. + + :param dict vals: Values to write on the recordset. + :return: Result of the parent ``write`` call. + :rtype: bool + :raises UserError: If auditlog is currently in ClickHouse read-only mode. + """ + if _is_clickhouse_readonly_mode(self.env): + _raise_clickhouse_readonly(self.env) + return super().write(vals) + + def unlink(self): + """Block deletion of auditlog log line records in FDW read mode. + + :return: Result of the parent ``unlink`` call. + :rtype: bool + :raises UserError: If auditlog is currently in ClickHouse read-only mode. + """ + if _is_clickhouse_readonly_mode(self.env): + _raise_clickhouse_readonly(self.env) + return super().unlink() diff --git a/auditlog_clickhouse_read/pyproject.toml b/auditlog_clickhouse_read/pyproject.toml new file mode 100644 index 00000000000..4231d0cccb3 --- /dev/null +++ b/auditlog_clickhouse_read/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/auditlog_clickhouse_read/readme/CONFIGURE.md b/auditlog_clickhouse_read/readme/CONFIGURE.md new file mode 100644 index 00000000000..ca0ad3c51fe --- /dev/null +++ b/auditlog_clickhouse_read/readme/CONFIGURE.md @@ -0,0 +1,39 @@ +To configure this module, you need to: + +1. Make sure the PostgreSQL extension `pg_clickhouse` is installed and available + on the PostgreSQL server used by Odoo. + +2. Make sure the ClickHouse database is reachable from the Odoo server. + +3. Make sure the audit log tables already exist in ClickHouse. + +4. Activate developer mode in Odoo. + +5. Go to *Settings > Technical > Auditlog > ClickHouse Configurations*. + +6. Open the active ClickHouse configuration used for audit log export. + +7. Fill in or verify the connection parameters: + + - *Hostname or IP* + - *TCP Port* + - *Database name* + - *User* + - *Password* + +8. Use *Test Connection* to verify that Odoo can connect to ClickHouse. + +9. Use *Create Auditlog Tables* if the ClickHouse audit log tables have not yet + been created. + +10. Click *Enable FDW read* to switch standard Odoo audit log views to + ClickHouse-backed foreign tables. + +Important notes: + +- Only the active ClickHouse configuration can enable FDW read. +- The PostgreSQL user used by Odoo must have the required privileges to create + and use FDW objects. +- While FDW read is enabled, the active ClickHouse configuration cannot be + deactivated, deleted, or changed for connection-related fields until FDW read + is disabled. diff --git a/auditlog_clickhouse_read/readme/CONTEXT.md b/auditlog_clickhouse_read/readme/CONTEXT.md new file mode 100644 index 00000000000..b9c0a266c2b --- /dev/null +++ b/auditlog_clickhouse_read/readme/CONTEXT.md @@ -0,0 +1,30 @@ +Business need: + +In some deployments, audit logs grow quickly and become expensive to keep and +query only in PostgreSQL. At the same time, auditors and administrators still +need to review audit trails from the standard Odoo interface without learning +new tools or getting direct access to external databases. + +This module is useful in environments where audit log storage is moved to +ClickHouse, but end users must continue working with the standard Odoo Audit Log +screens. + +Approach: + +The module keeps the usual Odoo audit log interface while changing the read +source behind it. Instead of reading audit log data from local PostgreSQL audit +tables, Odoo can read it through PostgreSQL foreign tables backed by ClickHouse. + +This allows administrators to keep the familiar menus, forms, filters, and +grouping options while using ClickHouse as the effective source for audit log +reads. + +Useful information: + +The module is especially useful in databases with high audit log volumes or in +setups where audit data should be stored outside the main PostgreSQL audit +tables. + +When FDW read mode is enabled, audit log records are read-only in Odoo. If +needed, administrators can disable FDW read mode and restore reading from the +local PostgreSQL audit log tables. diff --git a/auditlog_clickhouse_read/readme/CONTRIBUTORS.md b/auditlog_clickhouse_read/readme/CONTRIBUTORS.md new file mode 100644 index 00000000000..f2ec264300f --- /dev/null +++ b/auditlog_clickhouse_read/readme/CONTRIBUTORS.md @@ -0,0 +1,4 @@ +- [Cetmix](https://cetmix.com/) + - Ivan Sokolov + - George Smirnov + - Dmitry Meita diff --git a/auditlog_clickhouse_read/readme/CREDITS.md b/auditlog_clickhouse_read/readme/CREDITS.md new file mode 100644 index 00000000000..ce94d167cfd --- /dev/null +++ b/auditlog_clickhouse_read/readme/CREDITS.md @@ -0,0 +1,3 @@ +The development of this module has been financially supported by: + +- Geschäftsstelle Sozialinfo diff --git a/auditlog_clickhouse_read/readme/DESCRIPTION.md b/auditlog_clickhouse_read/readme/DESCRIPTION.md new file mode 100644 index 00000000000..5b7879e2801 --- /dev/null +++ b/auditlog_clickhouse_read/readme/DESCRIPTION.md @@ -0,0 +1,7 @@ +This module extends the audit log integration with ClickHouse to let Odoo read +audit log data through PostgreSQL Foreign Data Wrapper (FDW). + +When FDW read mode is enabled, standard Odoo audit log views continue to work +without additional user tools or direct database access, while the data is read +from ClickHouse. Audit log records become read-only in Odoo while this mode is +active. diff --git a/auditlog_clickhouse_read/readme/USAGE.md b/auditlog_clickhouse_read/readme/USAGE.md new file mode 100644 index 00000000000..52f3d4cbd93 --- /dev/null +++ b/auditlog_clickhouse_read/readme/USAGE.md @@ -0,0 +1,26 @@ +To use this module, you need to: + +1. Go to *Settings > Technical > Auditlog > ClickHouse Configurations*. + +2. Open the active ClickHouse configuration. + +3. Click *Enable FDW read*. + +4. Open the standard audit log menus in Odoo: + + - *Settings > Technical > Audit > Logs* + +5. Review audit log records as usual from the standard Odoo interface. + +6. Use the existing search, filters, and group by options in audit log views to + analyze audit data stored in ClickHouse. + +7. Open an audited record and use the standard *View Logs* action when + available. The action continues to open the related audit log entries through + the standard Odoo interface. + +Important notes: + +- While FDW read mode is enabled, audit log records are read-only in Odoo. +- To return to local PostgreSQL audit log tables, go back to the active + ClickHouse configuration and click *Disable FDW read*. diff --git a/auditlog_clickhouse_read/static/description/index.html b/auditlog_clickhouse_read/static/description/index.html new file mode 100644 index 00000000000..7b8db0ac890 --- /dev/null +++ b/auditlog_clickhouse_read/static/description/index.html @@ -0,0 +1,532 @@ + + + + + +Read auditlog records stored in clickhouse. + + + +
+

Read auditlog records stored in clickhouse.

+ + +

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

+

This module extends the audit log integration with ClickHouse to let +Odoo read audit log data through PostgreSQL Foreign Data Wrapper (FDW).

+

When FDW read mode is enabled, standard Odoo audit log views continue to +work without additional user tools or direct database access, while the +data is read from ClickHouse. Audit log records become read-only in Odoo +while this mode is active.

+

Table of contents

+ +
+

Use Cases / Context

+

Business need:

+

In some deployments, audit logs grow quickly and become expensive to +keep and query only in PostgreSQL. At the same time, auditors and +administrators still need to review audit trails from the standard Odoo +interface without learning new tools or getting direct access to +external databases.

+

This module is useful in environments where audit log storage is moved +to ClickHouse, but end users must continue working with the standard +Odoo Audit Log screens.

+

Approach:

+

The module keeps the usual Odoo audit log interface while changing the +read source behind it. Instead of reading audit log data from local +PostgreSQL audit tables, Odoo can read it through PostgreSQL foreign +tables backed by ClickHouse.

+

This allows administrators to keep the familiar menus, forms, filters, +and grouping options while using ClickHouse as the effective source for +audit log reads.

+

Useful information:

+

The module is especially useful in databases with high audit log volumes +or in setups where audit data should be stored outside the main +PostgreSQL audit tables.

+

When FDW read mode is enabled, audit log records are read-only in Odoo. +If needed, administrators can disable FDW read mode and restore reading +from the local PostgreSQL audit log tables.

+
+
+

Configuration

+

To configure this module, you need to:

+
    +
  1. Make sure the PostgreSQL extension pg_clickhouse is installed +and available on the PostgreSQL server used by Odoo.
  2. +
  3. Make sure the ClickHouse database is reachable from the Odoo server.
  4. +
  5. Make sure the audit log tables already exist in ClickHouse.
  6. +
  7. Activate developer mode in Odoo.
  8. +
  9. Go to Settings > Technical > Auditlog > ClickHouse Configurations.
  10. +
  11. Open the active ClickHouse configuration used for audit log export.
  12. +
  13. Fill in or verify the connection parameters:
      +
    • Hostname or IP
    • +
    • TCP Port
    • +
    • Database name
    • +
    • User
    • +
    • Password
    • +
    +
  14. +
  15. Use Test Connection to verify that Odoo can connect to ClickHouse.
  16. +
  17. Use Create Auditlog Tables if the ClickHouse audit log tables have +not yet been created.
  18. +
  19. Click Enable FDW read to switch standard Odoo audit log views to +ClickHouse-backed foreign tables.
  20. +
+

Important notes:

+
    +
  • Only the active ClickHouse configuration can enable FDW read.
  • +
  • The PostgreSQL user used by Odoo must have the required privileges to +create and use FDW objects.
  • +
  • While FDW read is enabled, the active ClickHouse configuration cannot +be deactivated, deleted, or changed for connection-related fields +until FDW read is disabled.
  • +
+
+
+

Usage

+

To use this module, you need to:

+
    +
  1. Go to Settings > Technical > Auditlog > ClickHouse Configurations.
  2. +
  3. Open the active ClickHouse configuration.
  4. +
  5. Click Enable FDW read.
  6. +
  7. Open the standard audit log menus in Odoo:
      +
    • Settings > Technical > Audit > Logs
    • +
    +
  8. +
  9. Review audit log records as usual from the standard Odoo interface.
  10. +
  11. Use the existing search, filters, and group by options in audit log +views to analyze audit data stored in ClickHouse.
  12. +
  13. Open an audited record and use the standard View Logs action when +available. The action continues to open the related audit log entries +through the standard Odoo interface.
  14. +
+

Important notes:

+
    +
  • While FDW read mode is enabled, audit log records are read-only in +Odoo.
  • +
  • To return to local PostgreSQL audit log tables, go back to the active +ClickHouse configuration and click Disable FDW read.
  • +
+
+
+

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

+
    +
  • Cetmix
  • +
+
+
+

Contributors

+
    +
  • Cetmix
      +
    • Ivan Sokolov
    • +
    • George Smirnov
    • +
    • Dmitry Meita
    • +
    +
  • +
+
+
+

Other credits

+

The development of this module has been financially supported by:

+
    +
  • Geschäftsstelle Sozialinfo
  • +
+
+
+

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/server-tools project on GitHub.

+

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

+
+
+
+ + diff --git a/auditlog_clickhouse_read/tests/__init__.py b/auditlog_clickhouse_read/tests/__init__.py new file mode 100644 index 00000000000..40ea155d2ef --- /dev/null +++ b/auditlog_clickhouse_read/tests/__init__.py @@ -0,0 +1,5 @@ +# Copyright (C) 2026 Cetmix OÜ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import test_auditlog_clickhouse_config +from . import test_auditlog_readonly diff --git a/auditlog_clickhouse_read/tests/common.py b/auditlog_clickhouse_read/tests/common.py new file mode 100644 index 00000000000..607035519d0 --- /dev/null +++ b/auditlog_clickhouse_read/tests/common.py @@ -0,0 +1,85 @@ +# Copyright (C) 2026 Cetmix OÜ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo.addons.base.tests.common import BaseCommon + + +class AuditlogClickhouseReadCommon(BaseCommon): + """Shared test helpers for ``auditlog_clickhouse_read`` test cases. + + The class provides: + + - a test environment with tracking disabled; + - access to the ``auditlog.clickhouse.config`` model; + - cleanup helpers to reset configuration state between test suites; + - a factory method for creating test ClickHouse configurations. + """ + + @classmethod + def setUpClass(cls): + """Prepare shared test state for read-module test classes. + + The method: + + - disables tracking in the test environment; + - caches the configuration model on ``cls.Config``; + - resets previously created ClickHouse read test data. + """ + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + cls.Config = cls.env["auditlog.clickhouse.config"].with_context( + tracking_disable=True + ) + cls._cleanup_read_test_data() + + @classmethod + def tearDownClass(cls): + """Clean up shared ClickHouse read test data after the test class. + + The cleanup is executed in a ``try/finally`` block to ensure that the + base class teardown still runs even if local cleanup fails. + """ + try: + cls._cleanup_read_test_data() + finally: + super().tearDownClass() + + @classmethod + def _cleanup_read_test_data(cls): + """Reset ClickHouse configuration state created by read-module tests. + + All existing ``auditlog.clickhouse.config`` records are forced into a + neutral state so test cases do not influence each other through active + or FDW-enabled configurations. + """ + configs = ( + cls.env["auditlog.clickhouse.config"] + .sudo() + .with_context(tracking_disable=True) + .search([]) + ) + if configs: + configs.write({"fdw_enabled": False}) + configs.write({"is_active": False}) + + @classmethod + def create_config(cls, **vals): + """Create a ClickHouse configuration record with test defaults. + + Default values are sufficient for most unit tests and can be overridden + through keyword arguments. + + :param dict vals: Field values overriding the default configuration. + :return: Newly created ClickHouse configuration record. + :rtype: odoo.models.Model + """ + defaults = { + "host": "127.0.0.1", + "port": 9000, + "database": "db", + "user": "default", + "password": "123", + "is_active": False, + } + defaults.update(vals) + return cls.Config.create(defaults) diff --git a/auditlog_clickhouse_read/tests/test_auditlog_clickhouse_config.py b/auditlog_clickhouse_read/tests/test_auditlog_clickhouse_config.py new file mode 100644 index 00000000000..3b8d5842a1d --- /dev/null +++ b/auditlog_clickhouse_read/tests/test_auditlog_clickhouse_config.py @@ -0,0 +1,612 @@ +# Copyright (C) 2026 Cetmix OÜ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from unittest.mock import patch + +from odoo.exceptions import UserError +from odoo.tests import tagged + +from .common import AuditlogClickhouseReadCommon + + +class DummyPrivilegeError(Exception): + pgcode = "42501" + + +@tagged("-at_install", "post_install") +class TestAuditlogClickhouseReadHelpers(AuditlogClickhouseReadCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.config = cls.create_config(is_active=True) + + def test_01_relation_kind_returns_none_when_relation_missing(self): + with ( + patch.object(self.env.cr, "execute"), + patch.object(self.env.cr, "fetchone", side_effect=[(None,)]), + ): + kind = self.config._relation_kind("public", "auditlog_log") + + self.assertIsNone(kind) + + def test_02_relation_kind_returns_pg_class_kind(self): + with ( + patch.object(self.env.cr, "execute") as execute, + patch.object( + self.env.cr, + "fetchone", + side_effect=[("public.auditlog_log",), ("f",)], + ), + ): + kind = self.config._relation_kind("public", "auditlog_log") + + self.assertEqual(kind, "f") + self.assertEqual(execute.call_count, 2) + + def test_03_get_auditlog_read_mode_fdw(self): + with patch.object( + type(self.config), + "_relation_kind", + autospec=True, + side_effect=["f", "f"], + ): + self.assertEqual(self.config._get_auditlog_read_mode(), "fdw") + + def test_04_get_auditlog_read_mode_postgres(self): + with patch.object( + type(self.config), + "_relation_kind", + autospec=True, + side_effect=["r", "r"], + ): + self.assertEqual(self.config._get_auditlog_read_mode(), "postgres") + + def test_05_get_auditlog_read_mode_mixed(self): + with patch.object( + type(self.config), + "_relation_kind", + autospec=True, + side_effect=["r", "f"], + ): + self.assertEqual(self.config._get_auditlog_read_mode(), "mixed") + + def test_06_backup_helpers(self): + with patch.object( + type(self.config), + "_relation_kind", + autospec=True, + side_effect=["r", "r", None, "r"], + ): + self.assertTrue(self.config._backup_tables_exist()) + self.assertTrue(self.config._any_backup_object_exists()) + + def test_07_describe_relation_kind(self): + self.assertEqual(self.config._describe_relation_kind(None), "missing") + self.assertEqual(self.config._describe_relation_kind("r"), "regular table") + self.assertEqual(self.config._describe_relation_kind("f"), "foreign table") + self.assertEqual(self.config._describe_relation_kind("v"), "view") + self.assertIn("unexpected relation", self.config._describe_relation_kind("x")) + + def test_08_raise_inconsistent_schema_state(self): + with patch.object( + type(self.config), + "_relation_kind", + autospec=True, + side_effect=["r", "f", None, "r"], + ): + with self.assertRaises(UserError) as err: + self.config._raise_inconsistent_schema_state() + + message = str(err.exception) + self.assertIn( + "Auditlog read mode is in an inconsistent PostgreSQL state", message + ) + self.assertIn("auditlog_log", message) + self.assertIn("auditlog_log_line", message) + + def test_09_raise_fdw_setup_error_privileges(self): + with self.assertRaises(UserError) as err: + self.config._raise_fdw_setup_error( + "Failed to create or update the FDW server", + DummyPrivilegeError("permission denied"), + ) + + message = str(err.exception) + self.assertIn( + "The current PostgreSQL user does not have enough privileges", message + ) + self.assertIn("clickhouse_fdw", message) + self.assertIn("auditlog_clickhouse_srv", message) + + def test_10_raise_fdw_setup_error_generic(self): + with self.assertRaises(UserError) as err: + self.config._raise_fdw_setup_error( + "Failed to create or update the FDW server", + Exception("boom"), + ) + + self.assertIn("Failed to create or update the FDW server", str(err.exception)) + self.assertIn("boom", str(err.exception)) + + +@tagged("-at_install", "post_install") +class TestAuditlogClickhouseReadProtection(AuditlogClickhouseReadCommon): + def test_01_deactivate_blocked_when_fdw_enabled(self): + config = self.create_config(is_active=True) + config.write({"fdw_enabled": True}) + + with self.assertRaises(UserError): + config.write({"is_active": False}) + + def test_02_connection_change_blocked_when_fdw_enabled(self): + config = self.create_config(is_active=True) + config.write({"fdw_enabled": True}) + + with self.assertRaises(UserError): + config.write({"host": "clickhouse.internal"}) + + with self.assertRaises(UserError): + config.write({"database": "other_db"}) + + def test_03_activation_blocked_if_other_fdw_config_exists(self): + config_1 = self.create_config(is_active=True, host="h1") + config_1.write({"fdw_enabled": True}) + + config_2 = self.create_config(is_active=False, host="h2") + + with self.assertRaises(UserError): + config_2.write({"is_active": True}) + + def test_04_unlink_blocked_when_fdw_enabled(self): + config = self.create_config(is_active=True) + config.write({"fdw_enabled": True}) + + with self.assertRaises(UserError): + config.unlink() + + +@tagged("-at_install", "post_install") +class TestAuditlogClickhouseReadActions(AuditlogClickhouseReadCommon): + def test_01_enable_requires_active_config(self): + config = self.create_config(is_active=False) + + with self.assertRaises(UserError): + config.action_enable_fdw_read() + + def test_02_enable_is_idempotent_when_already_fdw(self): + config = self.create_config(is_active=True) + + with patch.object( + type(config), + "_get_auditlog_read_mode", + autospec=True, + return_value="fdw", + ): + action = config.action_enable_fdw_read() + + config.invalidate_recordset() + self.assertTrue(config.fdw_enabled) + self.assertEqual(action["params"]["type"], "info") + + def test_03_enable_rejects_mixed_state(self): + config = self.create_config(is_active=True) + + with patch.object( + type(config), + "_get_auditlog_read_mode", + autospec=True, + return_value="mixed", + ): + with self.assertRaises(UserError): + config.action_enable_fdw_read() + + def test_04_enable_rejects_stale_backup_objects(self): + config = self.create_config(is_active=True) + + with ( + patch.object( + type(config), + "_get_auditlog_read_mode", + autospec=True, + return_value="postgres", + ), + patch.object( + type(config), + "_any_backup_object_exists", + autospec=True, + return_value=True, + ), + ): + with self.assertRaises(UserError): + config.action_enable_fdw_read() + + def test_05_enable_success_path(self): + config = self.create_config(is_active=True) + + with ( + patch.object( + type(config), + "_get_auditlog_read_mode", + autospec=True, + return_value="postgres", + ), + patch.object( + type(config), + "_any_backup_object_exists", + autospec=True, + return_value=False, + ), + patch.object( + type(config), + "_ensure_pg_clickhouse_extension", + autospec=True, + ) as ensure_extension, + patch.object( + type(config), + "_create_or_update_fdw_server", + autospec=True, + ) as update_server, + patch.object( + type(config), + "_create_or_update_fdw_user_mapping", + autospec=True, + ) as update_mapping, + patch.object( + type(config), + "_swap_auditlog_tables_to_fdw", + autospec=True, + ) as swap_tables, + patch.object( + type(config), + "_healthcheck_fdw_read", + autospec=True, + ) as healthcheck, + ): + action = config.action_enable_fdw_read() + + config.invalidate_recordset() + self.assertTrue(config.fdw_enabled) + self.assertEqual(action["params"]["type"], "success") + ensure_extension.assert_called_once_with(config) + update_server.assert_called_once_with(config) + update_mapping.assert_called_once_with(config) + swap_tables.assert_called_once_with(config) + healthcheck.assert_called_once_with(config) + + def test_06_enable_healthcheck_failure_keeps_flag_false(self): + config = self.create_config(is_active=True) + + with ( + patch.object( + type(config), + "_get_auditlog_read_mode", + autospec=True, + return_value="postgres", + ), + patch.object( + type(config), + "_any_backup_object_exists", + autospec=True, + return_value=False, + ), + patch.object( + type(config), + "_ensure_pg_clickhouse_extension", + autospec=True, + ), + patch.object( + type(config), + "_create_or_update_fdw_server", + autospec=True, + ), + patch.object( + type(config), + "_create_or_update_fdw_user_mapping", + autospec=True, + ), + patch.object( + type(config), + "_swap_auditlog_tables_to_fdw", + autospec=True, + ), + patch.object( + type(config), + "_healthcheck_fdw_read", + autospec=True, + side_effect=UserError("healthcheck failed"), + ), + ): + with self.assertRaises(UserError): + config.action_enable_fdw_read() + + config.invalidate_recordset() + self.assertFalse(config.fdw_enabled) + + def test_07_disable_is_idempotent_when_already_postgres(self): + config = self.create_config(is_active=True) + config.write({"fdw_enabled": True}) + + with patch.object( + type(config), + "_get_auditlog_read_mode", + autospec=True, + return_value="postgres", + ): + action = config.action_disable_fdw_read() + + config.invalidate_recordset() + self.assertFalse(config.fdw_enabled) + self.assertEqual(action["params"]["type"], "info") + + def test_08_disable_rejects_mixed_state(self): + config = self.create_config(is_active=True) + config.write({"fdw_enabled": True}) + + with patch.object( + type(config), + "_get_auditlog_read_mode", + autospec=True, + return_value="mixed", + ): + with self.assertRaises(UserError): + config.action_disable_fdw_read() + + def test_09_disable_rejects_missing_backups(self): + config = self.create_config(is_active=True) + config.write({"fdw_enabled": True}) + + with ( + patch.object( + type(config), + "_get_auditlog_read_mode", + autospec=True, + return_value="fdw", + ), + patch.object( + type(config), + "_backup_tables_exist", + autospec=True, + return_value=False, + ), + ): + with self.assertRaises(UserError): + config.action_disable_fdw_read() + + def test_10_disable_success_path(self): + config = self.create_config(is_active=True) + config.write({"fdw_enabled": True}) + + with ( + patch.object( + type(config), + "_get_auditlog_read_mode", + autospec=True, + return_value="fdw", + ), + patch.object( + type(config), + "_backup_tables_exist", + autospec=True, + return_value=True, + ), + patch.object( + type(config), + "_restore_auditlog_tables_from_backup", + autospec=True, + ) as restore_tables, + ): + action = config.action_disable_fdw_read() + + config.invalidate_recordset() + self.assertFalse(config.fdw_enabled) + self.assertEqual(action["params"]["type"], "success") + restore_tables.assert_called_once_with(config) + + +@tagged("-at_install", "post_install") +class TestAuditlogClickhouseReadDDLHelpers(AuditlogClickhouseReadCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.config = cls.create_config(is_active=True) + + def test_01_drop_foreign_table_if_exists(self): + with ( + patch.object( + type(self.config), + "_relation_kind", + autospec=True, + return_value="f", + ), + patch.object(self.env.cr, "execute") as execute, + ): + self.config._drop_foreign_table_if_exists("public", "auditlog_log") + + execute.assert_called_once() + + def test_02_drop_foreign_table_skips_non_fdw_relation(self): + with ( + patch.object( + type(self.config), + "_relation_kind", + autospec=True, + return_value="r", + ), + patch.object(self.env.cr, "execute") as execute, + ): + self.config._drop_foreign_table_if_exists("public", "auditlog_log") + + execute.assert_not_called() + + def test_03_rename_table_if_exists(self): + with ( + patch.object( + type(self.config), + "_relation_kind", + autospec=True, + return_value="r", + ), + patch.object(self.env.cr, "execute") as execute, + ): + self.config._rename_table_if_exists( + "public", + "auditlog_log", + "auditlog_log_pg_backup", + ) + + execute.assert_called_once() + + def test_04_rename_table_skips_non_regular_relation(self): + with ( + patch.object( + type(self.config), + "_relation_kind", + autospec=True, + return_value="f", + ), + patch.object(self.env.cr, "execute") as execute, + ): + self.config._rename_table_if_exists( + "public", + "auditlog_log", + "auditlog_log_pg_backup", + ) + + execute.assert_not_called() + + def test_05_drop_view_if_exists(self): + with patch.object(self.env.cr, "execute") as execute: + self.config._drop_view_if_exists("public", "auditlog_log_line_view") + + execute.assert_called_once() + + def test_06_ensure_sequences(self): + with patch.object(self.env.cr, "execute") as execute: + self.config._ensure_sequences() + + self.assertEqual(execute.call_count, 2) + + def test_07_create_foreign_tables(self): + _ = self.config.database + + with patch.object(self.env.cr, "execute") as execute: + self.config._create_foreign_tables("public") + + self.assertEqual(execute.call_count, 2) + + def test_08_recreate_auditlog_log_line_view(self): + with ( + patch.object( + type(self.config), + "_drop_view_if_exists", + autospec=True, + ) as drop_view, + patch.object(self.env.cr, "execute") as execute, + ): + self.config._recreate_auditlog_log_line_view("public") + + drop_view.assert_called_once_with( + self.config, "public", "auditlog_log_line_view" + ) + execute.assert_called_once() + + def test_09_swap_auditlog_tables_to_fdw_calls_expected_helpers(self): + with ( + patch.object( + type(self.config), + "_drop_view_if_exists", + autospec=True, + ) as drop_view, + patch.object( + type(self.config), + "_drop_foreign_table_if_exists", + autospec=True, + ) as drop_foreign, + patch.object( + type(self.config), + "_rename_table_if_exists", + autospec=True, + ) as rename_table, + patch.object( + type(self.config), + "_ensure_sequences", + autospec=True, + ) as ensure_sequences, + patch.object( + type(self.config), + "_create_foreign_tables", + autospec=True, + ) as create_foreign, + patch.object( + type(self.config), + "_recreate_auditlog_log_line_view", + autospec=True, + ) as recreate_view, + ): + self.config._swap_auditlog_tables_to_fdw() + + drop_view.assert_called_once_with( + self.config, "public", "auditlog_log_line_view" + ) + self.assertEqual(drop_foreign.call_count, 2) + self.assertEqual(rename_table.call_count, 2) + ensure_sequences.assert_called_once_with(self.config) + create_foreign.assert_called_once_with(self.config, "public") + recreate_view.assert_called_once_with(self.config, "public") + + def test_10_restore_auditlog_tables_from_backup_calls_expected_helpers(self): + with ( + patch.object( + type(self.config), + "_drop_view_if_exists", + autospec=True, + ) as drop_view, + patch.object( + type(self.config), + "_drop_foreign_table_if_exists", + autospec=True, + ) as drop_foreign, + patch.object( + type(self.config), + "_rename_table_if_exists", + autospec=True, + ) as rename_table, + patch.object( + type(self.config), + "_recreate_auditlog_log_line_view", + autospec=True, + ) as recreate_view, + ): + self.config._restore_auditlog_tables_from_backup() + + drop_view.assert_called_once_with( + self.config, "public", "auditlog_log_line_view" + ) + self.assertEqual(drop_foreign.call_count, 2) + self.assertEqual(rename_table.call_count, 2) + recreate_view.assert_called_once_with(self.config, "public") + + def test_11_healthcheck_success(self): + with ( + patch.object(self.env.cr, "execute") as execute, + patch.object(self.env.cr, "fetchone", return_value=(1,)), + ): + self.config._healthcheck_fdw_read() + + execute.assert_called_once() + + def test_12_healthcheck_raises_usererror_on_failure(self): + original_execute = self.env.cr.execute + + def mocked_execute(query, params=None, log_exceptions=None): + query_str = str(query) + if "SELECT 1 FROM" in query_str and "auditlog_log" in query_str: + raise Exception("fdw boom") + return original_execute(query, params, log_exceptions=log_exceptions) + + with patch.object(self.env.cr, "execute", side_effect=mocked_execute): + with self.assertRaises(UserError) as err: + self.config._healthcheck_fdw_read() + + self.assertIn("cannot be read through PostgreSQL FDW", str(err.exception)) + self.assertIn("fdw boom", str(err.exception)) diff --git a/auditlog_clickhouse_read/tests/test_auditlog_readonly.py b/auditlog_clickhouse_read/tests/test_auditlog_readonly.py new file mode 100644 index 00000000000..d5ef188a16e --- /dev/null +++ b/auditlog_clickhouse_read/tests/test_auditlog_readonly.py @@ -0,0 +1,153 @@ +# Copyright (C) 2026 Cetmix OÜ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from unittest.mock import patch + +from odoo.exceptions import UserError +from odoo.tests import tagged + +from odoo.addons.auditlog_clickhouse_read.models.auditlog_readonly import ( + _is_clickhouse_readonly_mode, +) + +from .common import AuditlogClickhouseReadCommon + + +@tagged("-at_install", "post_install") +class TestAuditlogClickhouseReadonly(AuditlogClickhouseReadCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.partner_model = cls.env.ref("base.model_res_partner") + cls.name_field = cls.env["ir.model.fields"].search( + [("model", "=", "res.partner"), ("name", "=", "name")], + limit=1, + ) + + def _create_log(self): + return ( + self.env["auditlog.log"] + .with_context(tracking_disable=True) + .create( + { + "name": "Readonly test log", + "model_id": self.partner_model.id, + "res_id": 1, + "user_id": self.env.user.id, + "method": "write", + } + ) + ) + + def _create_log_line(self, log): + return ( + self.env["auditlog.log.line"] + .with_context(tracking_disable=True) + .create( + { + "log_id": log.id, + "field_id": self.name_field.id, + "old_value_text": "old", + "new_value_text": "new", + } + ) + ) + + def test_01_is_clickhouse_readonly_mode_true(self): + with patch.object( + type(self.env["auditlog.clickhouse.config"]), + "_get_auditlog_read_mode", + autospec=True, + return_value="fdw", + ): + self.assertTrue(_is_clickhouse_readonly_mode(self.env)) + + def test_02_is_clickhouse_readonly_mode_false(self): + with patch.object( + type(self.env["auditlog.clickhouse.config"]), + "_get_auditlog_read_mode", + autospec=True, + return_value="postgres", + ): + self.assertFalse(_is_clickhouse_readonly_mode(self.env)) + + def test_03_auditlog_log_create_blocked(self): + with patch( + "odoo.addons.auditlog_clickhouse_read.models.auditlog_readonly." + "_is_clickhouse_readonly_mode", + return_value=True, + ): + with self.assertRaises(UserError): + self.env["auditlog.log"].create( + { + "name": "Blocked log", + "model_id": self.partner_model.id, + "res_id": 1, + "user_id": self.env.user.id, + "method": "write", + } + ) + + def test_04_auditlog_log_write_blocked(self): + log = self._create_log() + + with patch( + "odoo.addons.auditlog_clickhouse_read.models.auditlog_readonly." + "_is_clickhouse_readonly_mode", + return_value=True, + ): + with self.assertRaises(UserError): + log.write({"name": "blocked"}) + + def test_05_auditlog_log_unlink_blocked(self): + log = self._create_log() + + with patch( + "odoo.addons.auditlog_clickhouse_read.models.auditlog_readonly." + "_is_clickhouse_readonly_mode", + return_value=True, + ): + with self.assertRaises(UserError): + log.unlink() + + def test_06_auditlog_log_line_create_blocked(self): + log = self._create_log() + + with patch( + "odoo.addons.auditlog_clickhouse_read.models.auditlog_readonly." + "_is_clickhouse_readonly_mode", + return_value=True, + ): + with self.assertRaises(UserError): + self.env["auditlog.log.line"].create( + { + "log_id": log.id, + "field_id": self.name_field.id, + "old_value_text": "old", + "new_value_text": "new", + } + ) + + def test_07_auditlog_log_line_write_blocked(self): + log = self._create_log() + line = self._create_log_line(log) + + with patch( + "odoo.addons.auditlog_clickhouse_read.models.auditlog_readonly." + "_is_clickhouse_readonly_mode", + return_value=True, + ): + with self.assertRaises(UserError): + line.write({"new_value_text": "blocked"}) + + def test_08_auditlog_log_line_unlink_blocked(self): + log = self._create_log() + line = self._create_log_line(log) + + with patch( + "odoo.addons.auditlog_clickhouse_read.models.auditlog_readonly." + "_is_clickhouse_readonly_mode", + return_value=True, + ): + with self.assertRaises(UserError): + line.unlink() diff --git a/auditlog_clickhouse_read/views/auditlog_clickhouse_config_views.xml b/auditlog_clickhouse_read/views/auditlog_clickhouse_config_views.xml new file mode 100644 index 00000000000..328f19ba954 --- /dev/null +++ b/auditlog_clickhouse_read/views/auditlog_clickhouse_config_views.xml @@ -0,0 +1,33 @@ + + + auditlog.clickhouse.config.form.read + auditlog.clickhouse.config + + + +