From 485756212d54896adf127edaf57b09ea43cd1817 Mon Sep 17 00:00:00 2001 From: Virginia Date: Thu, 29 Jan 2026 16:02:51 +0000 Subject: [PATCH 01/15] [ADD] pot_github_push: backport --- pot_github_push/README.rst | 64 ++++++++ pot_github_push/__init__.py | 33 +++++ pot_github_push/__manifest__.py | 12 ++ pot_github_push/wizard/__init__.py | 1 + .../wizard/pot_generator_wizard.py | 138 ++++++++++++++++++ 5 files changed, 248 insertions(+) create mode 100644 pot_github_push/README.rst create mode 100644 pot_github_push/__init__.py create mode 100644 pot_github_push/__manifest__.py create mode 100644 pot_github_push/wizard/__init__.py create mode 100644 pot_github_push/wizard/pot_generator_wizard.py diff --git a/pot_github_push/README.rst b/pot_github_push/README.rst new file mode 100644 index 00000000..845471a4 --- /dev/null +++ b/pot_github_push/README.rst @@ -0,0 +1,64 @@ +.. |company| replace:: ADHOC SA + +.. |company_logo| image:: https://raw.githubusercontent.com/ingadhoc/maintainer-tools/master/resources/adhoc-logo.png + :alt: ADHOC SA + :target: https://www.adhoc.com.ar + +.. |icon| image:: https://raw.githubusercontent.com/ingadhoc/maintainer-tools/master/resources/adhoc-icon.png + +.. image:: https://img.shields.io/badge/license-AGPL--3-blue.png + :target: https://www.gnu.org/licenses/agpl + :alt: License: AGPL-3 + +============= +POT Generator +============= + +Automatic POT (Portable Object Template) file generator for Odoo modules with GitHub API integration. + +Features +======== + +**POT Generation** + - Generate .pot files using Odoo's native ``trans_export`` + - Direct GitHub API push (no local Git required) + - Smart content comparison (ignores timestamp changes) + +**Integration** + - Runbot compatible execution + - Auto-execution on module installation + - Environment variable configuration + +Configuration +============= + +Set environment variables for GitHub integration:: + + export GITHUB_TOKEN="your_github_token" + export GITHUB_REPO_OWNER="your_organization" + export GITHUB_REPO_NAME="your_repository" + export GITHUB_BRANCH="your_branch" + +.. image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas + :alt: Try me on Runbot + :target: http://runbot.adhoc.com.ar/ + +Credits +======= + +Images +------ + +* |company| |icon| + +Contributors +------------ + +Maintainer +---------- + +|company_logo| + +This module is maintained by the |company|. + +To contribute to this module, please visit https://www.adhoc.com.ar. diff --git a/pot_github_push/__init__.py b/pot_github_push/__init__.py new file mode 100644 index 00000000..56c6798f --- /dev/null +++ b/pot_github_push/__init__.py @@ -0,0 +1,33 @@ +from . import wizard + +import logging +import ast +import os + +_logger = logging.getLogger(__name__) + + +def post_init_hook(env): + """Auto-generate POT files on installation + + Environment variables: + - MODULE_INFO: Dict with tuple key (repo_owner, repo_name) and modules list as value + {("owner", "repo"): ["module1", "module2"], ...} + - GITHUB_TOKEN: GitHub token (required) + - GITHUB_BRANCH: Target branch (required) + """ + module_info = os.getenv("MODULE_INFO", "{}") + github_token = os.getenv("GITHUB_TOKEN") + github_branch = os.getenv("GITHUB_BRANCH") + + if not module_info or module_info == "{}": + _logger.info("No modules specified for POT generation (MODULE_INFO)") + return False + + try: + module_info = ast.literal_eval(module_info) + except Exception as e: + _logger.error("Error parsing MODULE_INFO: %s", str(e)) + return False + + env["pot.generator"]._generate_pots(module_info, github_token, github_branch) diff --git a/pot_github_push/__manifest__.py b/pot_github_push/__manifest__.py new file mode 100644 index 00000000..8bc33c02 --- /dev/null +++ b/pot_github_push/__manifest__.py @@ -0,0 +1,12 @@ +{ + "name": "POT Generator", + "version": "18.0.1.0.0", + "category": "Tools", + "summary": "Helper module to generate POT files", + "author": "ADHOC SA", + "license": "AGPL-3", + "depends": ["base"], + "data": [], + "installable": True, + "post_init_hook": "post_init_hook", +} diff --git a/pot_github_push/wizard/__init__.py b/pot_github_push/wizard/__init__.py new file mode 100644 index 00000000..8d16e2f7 --- /dev/null +++ b/pot_github_push/wizard/__init__.py @@ -0,0 +1 @@ +from . import pot_generator_wizard diff --git a/pot_github_push/wizard/pot_generator_wizard.py b/pot_github_push/wizard/pot_generator_wizard.py new file mode 100644 index 00000000..158457e9 --- /dev/null +++ b/pot_github_push/wizard/pot_generator_wizard.py @@ -0,0 +1,138 @@ +import base64 +import contextlib +import io +import logging + +import requests +from odoo import api, models +from odoo.tools.translate import trans_export + +_logger = logging.getLogger(__name__) + + +class PotGenerator(models.AbstractModel): + _name = "pot.generator" + _description = "Simple POT Generator" + + @api.model + def _generate_pots(self, module_info, github_token, github_branch): + """Generate POT files for specified modules and push to GitHub + + :param module_info: Dict with tuple key (owner, repo) and modules list {("owner", "repo"): ["mod1"]} + :param github_token: GitHub API token + :param github_branch: Target branch name + """ + try: + for repo_key, module_names in module_info.items(): + # repo_key should be tuple (owner, repo) + if isinstance(repo_key, tuple): + repo_owner, repo_name = repo_key + else: + _logger.error("Invalid repo key type: %s", type(repo_key)) + continue + + for module_name in module_names: + content = self._generate_pot(module_name) + if content: + self._github_push(module_name, content, repo_owner, repo_name, github_token, github_branch) + return True + + except Exception as e: + _logger.exception("POT generation failed: %s", str(e)) + return False + + def _generate_pot(self, module_name): + """Generate single POT file""" + try: + # Get content using Odoo's trans_export + with contextlib.closing(io.BytesIO()) as buf: + trans_export(False, [module_name], buf, "po", self._cr) + return buf.getvalue().decode("utf-8") + except Exception as e: + _logger.exception("Failed POT generation for %s: %s", module_name, str(e)) + return False + + def _github_push(self, module_name, content, repo_owner, repo_name, github_token, branch): + """Push POT file to GitHub using API + + :param module_name: Name of the module + :param content: POT file content + :param repo_owner: GitHub repository owner + :param repo_name: GitHub repository name + :param github_token: GitHub API token + :param branch: Target branch name + """ + headers = {} + try: + # File path in repository + file_path = f"{module_name}/i18n/{module_name}.pot" + + # GitHub API headers + headers = {"Authorization": f"Bearer {github_token}", "Accept": "application/vnd.github.v3+json"} + + # Get current file SHA (if exists) + url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/contents/{file_path}" + params = {"ref": branch} + response = requests.get(url, headers=headers, params=params, timeout=30) + + sha = None + if response.status_code == 200: + file_info = response.json() + sha = file_info["sha"] + + # Compare content to avoid unnecessary pushes + existing_content = base64.b64decode(file_info["content"]).decode("utf-8") + if self._pot_content_equal(existing_content, content): + _logger.info("File %s content unchanged (ignoring timestamps), skipping push", file_path) + return True + + elif response.status_code == 404: + _logger.info("File %s does not exist, will create new", file_path) + else: + _logger.error("Error getting file info: %s", response.text) + return False + + content_encoded = base64.b64encode(content.encode("utf-8")).decode("utf-8") + + # Prepare commit data + commit_data = { + "message": f"[I18N] {module_name}: export source terms", + "content": content_encoded, + "branch": branch, + } + if sha: + commit_data["sha"] = sha + + # Push to GitHub + response = requests.put(url, json=commit_data, headers=headers, timeout=30) + if response.status_code in [200, 201]: + _logger.info("GitHub push completed for %s", module_name) + return True + else: + _logger.error("GitHub push failed for %s: %s", module_name, response.text) + return False + + except Exception as e: + _logger.error("GitHub push failed for %s: %s", module_name, str(e)) + return False + finally: + # Clear headers to avoid keeping sensitive token data in memory + headers.clear() + + def _pot_content_equal(self, content1, content2): + """Compare POT files ignoring timestamp changes""" + + def normalize_pot_content(content): + """Remove timestamp lines and normalize content for comparison""" + lines = content.strip().split("\n") + normalized_lines = [] + for line in lines: + # Skip POT-Creation-Date and PO-Revision-Date lines + if line.startswith('"POT-Creation-Date:') or line.startswith('"PO-Revision-Date:'): + continue + normalized_lines.append(line) + return "\n".join(normalized_lines) + + normalized1 = normalize_pot_content(content1) + normalized2 = normalize_pot_content(content2) + return normalized1 == normalized2 From bc8331753d40c5df20cc86ec62d1730f006847da Mon Sep 17 00:00:00 2001 From: Virginia Date: Thu, 29 Jan 2026 16:44:33 -0300 Subject: [PATCH 02/15] Update project.toml from template --- .copier-answers.yml | 2 +- .github/workflows/pre-commit.yml | 7 ++++++- .pre-commit-config.yaml | 2 ++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.copier-answers.yml b/.copier-answers.yml index 638b4ac4..ec83194c 100644 --- a/.copier-answers.yml +++ b/.copier-answers.yml @@ -1,5 +1,5 @@ # Do NOT update manually; changes here will be overwritten by Copier -_commit: 2f2f7c4 +_commit: a740779 _src_path: https://github.com/ingadhoc/addons-repo-template.git description: '' is_private: false diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index 349c52d8..baa05dbf 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -6,8 +6,13 @@ name: pre-commit on: push: - branches: "[0-9][0-9].0" + branches: + - "1[8-9].0" + - "[2-9][0-9].0" pull_request_target: + branches: + - "1[8-9].0*" + - "[2-9][0-9].0*" jobs: pre-commit: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fc269814..c4be55ff 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -29,6 +29,8 @@ repos: - id: check-docstring-first - id: check-executables-have-shebangs - id: check-merge-conflict + args: ['--assume-in-merge'] + exclude: '\.rst$' - id: check-symlinks - id: check-xml - id: check-yaml From 69042b25b826302f7f5f884a89116cf25c1fcea4 Mon Sep 17 00:00:00 2001 From: Franco Leyes Date: Thu, 29 Jan 2026 20:40:32 +0000 Subject: [PATCH 03/15] [IMP] export_bg: clean data rows before writing to Excel closes ingadhoc/miscellaneous#362 Signed-off-by: Filoquin adhoc --- export_bg/models/export_bg_mixin.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/export_bg/models/export_bg_mixin.py b/export_bg/models/export_bg_mixin.py index 234f6041..6a893e04 100644 --- a/export_bg/models/export_bg_mixin.py +++ b/export_bg/models/export_bg_mixin.py @@ -129,7 +129,8 @@ def _combine_chunks(self, export_id, export_format): ws.write_row(0, 0, chunk_data["headers"]) row_num = 1 for row in chunk_data["rows"]: - ws.write_row(row_num, 0, row) + cleaned_row = [str(cell) if isinstance(cell, (dict, list)) else cell for cell in row] + ws.write_row(row_num, 0, cleaned_row) row_num += 1 wb.close() chunks.unlink() From 6b4b4eaaade7af3a13838878461a30d63aa66cb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roc=C3=ADo=20Vega?= Date: Mon, 9 Feb 2026 09:19:47 -0300 Subject: [PATCH 04/15] [FIX] export_bg: Handle import_compat parameter correctly in background export When import_compat=False, avoid using field 'value' for data extraction to prevent issues when the exported data is used for record updates. - In import_compat mode: use name -> value -> id fallback chain - In regular export mode: use only name -> id (skip 'value') - Field labels (headers) are handled appropriately for each mode This ensures exported data maintains proper field references based on the intended use case (import vs display/update). closes ingadhoc/miscellaneous#367 Signed-off-by: Franco Leyes --- export_bg/models/export_bg_mixin.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/export_bg/models/export_bg_mixin.py b/export_bg/models/export_bg_mixin.py index 6a893e04..96096902 100644 --- a/export_bg/models/export_bg_mixin.py +++ b/export_bg/models/export_bg_mixin.py @@ -39,8 +39,18 @@ def _export_chunk_bg(self, data, export_id, export_format): ] ) - field_names = [f.get("name") or f.get("value") or f.get("id") for f in params["fields"]] - field_labels = [f.get("label") or f.get("string") for f in params["fields"]] + # Extract field names considering import_compat mode + import_compat = params.get("import_compat", True) + + # For field_names (data extraction), always use the technical field name + # Only use 'value' as fallback when import_compat=True (for import compatibility) + if import_compat: + field_names = [f.get("name") or f.get("value") or f.get("id") for f in params["fields"]] + field_labels = field_names # Use field names as headers for import compatibility + else: + # When not import_compat, use only 'name' or 'id' for field_names, not 'value' + field_names = [f.get("name") or f.get("id") for f in params["fields"]] + field_labels = [f.get("label") or f.get("string") for f in params["fields"]] export_data = self.export_data(field_names).get("datas", []) From 14eb546d51670f7b543ae24814c220f6430a36af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roc=C3=ADo=20Vega?= Date: Thu, 12 Feb 2026 12:49:56 -0300 Subject: [PATCH 05/15] [FIX] export_bg: Handle binary values in JSON export Encode bytes/bytearray/memoryview values as base64 strings during JSON export. Remove the leftover debugger breakpoint. closes ingadhoc/miscellaneous#371 Signed-off-by: Franco Leyes --- export_bg/models/export_bg_mixin.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/export_bg/models/export_bg_mixin.py b/export_bg/models/export_bg_mixin.py index 96096902..50dee5ca 100644 --- a/export_bg/models/export_bg_mixin.py +++ b/export_bg/models/export_bg_mixin.py @@ -14,6 +14,8 @@ class DateTimeEncoder(json.JSONEncoder): def default(self, obj): if isinstance(obj, (datetime, date, time)): return obj.isoformat() + if isinstance(obj, (bytes, bytearray, memoryview)): + return base64.b64encode(bytes(obj)).decode() return super().default(obj) From a70c9952aae0218bf9a1f0717ba0cf42c8301a86 Mon Sep 17 00:00:00 2001 From: adhoc-cicd-bot <116299102+adhoc-cicd-bot@users.noreply.github.com> Date: Wed, 18 Feb 2026 14:43:39 -0300 Subject: [PATCH 06/15] [UPD] Copilot instructions --- .github/copilot-instructions.md | 25 ++++++------------------- 1 file changed, 6 insertions(+), 19 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index a30ad3cf..783d6e4d 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -38,8 +38,6 @@ * Confirmar que todos los archivos usados (vistas, seguridad, datos, reportes, wizards) estén referenciados en el manifest. * Verificar dependencias declaradas: que no falten módulos requeridos ni se declaren innecesarios. -* **Regla de versión (obligatoria):** - Solo sugerir bump de versión si el `__manifest__.py` no incrementa `version` y se modificó la estructura de un modelo, una vista, o algún record .xml (ej. cambios en definición de campos, vistas XML, datos XML, seguridad). * Solo hacerlo una vez por revisión, aunque haya múltiples archivos afectados. --- @@ -61,7 +59,6 @@ * Verificar los archivos `ir.model.access.csv` para nuevos modelos: deben tener permisos mínimos necesarios. * No proponer abrir acceso global sin justificación. -* Si se agregan nuevos modelos o campos de control de acceso, **recordar el bump de versión** (ver sección de manifest). * Si se cambian `record rules`, revisar especialmente combinaciones multi-compañía y multi-website. ### Seguridad y rendimiento del ORM @@ -86,7 +83,7 @@ ## Cambios estructurales y scripts de migración – **cuestiones generales** -Cuando el diff sugiera **cambios de estructura de datos**, **siempre evaluar** si corresponde proponer un **script de migración** en `migrations/` (pre/post/end) **y recordar el bump de versión**. +Cuando el diff sugiera **cambios de estructura de datos**, **siempre evaluar** si corresponde proponer un **script de migración** en `migrations/` (pre/post/end). ### Reglas generales de estructura de `migrations/` @@ -283,7 +280,6 @@ def migrate(cr, registry): | ------------------ | -------------------------------------------------------------------------------------------------------- | | Modelos | Relaciones válidas; constraints; uso adecuado de `@api.depends`; `super()` correcto | | Vistas XML | Herencias correctas; campos válidos; adaptación a cambios de versión (p.ej. `` vs ``) | -| Manifest | **Bump de versión obligatorio** si hay cambios estructurales en modelos/vistas/records .xml; archivos referenciados | | Seguridad | Accesos mínimos necesarios; reglas revisadas | | Migraciones | **Si hay cambios estructurales, sugerir script en `migrations/` (pre/post/end)** y describir qué hace | | Rendimiento / ORM | Evitar loops costosos; no SQL innecesario; aprovechar las optimizaciones del ORM de la versión | @@ -291,15 +287,6 @@ def migrate(cr, registry): --- -## Heurística práctica para el bump de versión (general) - -* **SI** el diff modifica la estructura de un modelo, una vista, o algún record .xml (ej. cambios en definición de campos, vistas XML, datos XML, seguridad) - **Y** `__manifest__.py` no cambia `version` → **Sugerir bump**. -* **SI** hay scripts `migrations/pre_*.py` o `migrations/post_*.py` nuevos → **Sugerir al menos minor bump**. -* **SI** hay cambios que rompen compatibilidad (renombres, cambios de tipo con impacto, limpieza masiva de datos) → **Sugerir minor/major** según impacto. - ---- - ## Estilo del feedback (general) * Ser breve, claro y útil. Ejemplos: @@ -307,7 +294,7 @@ def migrate(cr, registry): * “El campo `partner_id` no se encuentra referenciado en la vista.” * “Este método redefine `write()` sin usar `super()`.” * “Tip: hay un error ortográfico en el nombre del parámetro.” - * **Bump + migración:** “Se renombra `old_ref` → `new_ref`: falta **bump de versión** y **pre-script** en `migrations/` para copiar valores antes del upgrade; añadir **post-script** para recompute del stored.” + * **Migración:** “Se renombra `old_ref` → `new_ref`: falta **pre-script** en `migrations/` para copiar valores antes del upgrade; añadir **post-script** para recompute del stored.” * Evitar explicaciones largas o reescrituras completas salvo que el cambio sea claro y necesario. * Priorizar comentarios en forma de **lista corta de puntos** (3–7 ítems) y frases breves en lugar de bloques de texto extensos. @@ -316,10 +303,10 @@ def migrate(cr, registry): ## Resumen operativo para Copilot -1. **Detecta cambios estructurales en modelos, vistas o records .xml → exige bump de `version` en `__manifest__.py` si no está incrementada.** -2. **Si hay cambio estructural (según la lista actualizada) → propone y describe script(s) de migración en `migrations/` (pre/post/end)**, con enfoque idempotente y en lotes. -3. Distingue entre: +1. **Si hay cambio estructural (según la lista actualizada) → propone y describe script(s) de migración en `migrations/` (pre/post/end)**, con enfoque idempotente y en lotes. +2. Distingue entre: * **cuestiones generales** (válidas para cualquier versión), * y **matices específicos de Odoo 18** (por ejemplo, uso de ``, passkeys, tours y comportamiento del framework). -4. Mantén el feedback **concreto, breve y accionable**. \ No newline at end of file + +3. Mantén el feedback **concreto, breve y accionable**. \ No newline at end of file From 5e7b28402cedf790465a5ffef01002590ddfb8a8 Mon Sep 17 00:00:00 2001 From: Felipe Garcia Suez Date: Thu, 12 Feb 2026 10:39:38 -0300 Subject: [PATCH 07/15] [FIX] account_statement_import_sheet_file_bg: Fixed JSON file data closes ingadhoc/miscellaneous#370 Signed-off-by: rov-adhoc --- .../models/account_statement_import.py | 35 +++++++++++++++++-- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/account_statement_import_sheet_file_bg/models/account_statement_import.py b/account_statement_import_sheet_file_bg/models/account_statement_import.py index e2b004bb..a99c9a64 100644 --- a/account_statement_import_sheet_file_bg/models/account_statement_import.py +++ b/account_statement_import_sheet_file_bg/models/account_statement_import.py @@ -7,6 +7,7 @@ from markupsafe import Markup from odoo import _, models +from odoo.exceptions import UserError from openpyxl import Workbook, load_workbook @@ -37,13 +38,28 @@ def import_file_button(self, wizard_data=None): if files: for idx, file in enumerate(files): + # Encode the file to string format, because background jobs cannot + # be executed if the parameters passed are not serializable (the original format is bytes). + # It is decoded back in import_file_button to be processed normally. + csv_or_xls = None + file_str = file + if not isinstance(file, str): + try: + file_bytes = base64.b64decode(file) + file_str = file_bytes.decode("utf-8") + csv_or_xls = "csv" + except Exception: + file_str = base64.b64encode(file_bytes).decode("ascii") + csv_or_xls = "xls" + # Create wizard data to be passed to bg job wizard_data = { - "statement_file": file, + "statement_file": file_str, "statement_filename": self.statement_filename, "sheet_mapping_id": self.sheet_mapping_id.id, "part_number": idx + 1, "total_parts": len(files), + "csv_or_xls": csv_or_xls, } # Call bg_enqueue on empty recordset and pass data as kwargs # Add part number to job name for clarity @@ -86,6 +102,16 @@ def import_file_button(self, wizard_data=None): # Extract part info before creating wizard part_number = wizard_data.pop("part_number", None) total_parts = wizard_data.pop("total_parts", None) + csv_or_xls = wizard_data.pop("csv_or_xls", None) + # Decode file from string back to bytes based on file type + statement_file = wizard_data.get("statement_file") + if statement_file and isinstance(statement_file, str): + if csv_or_xls == "csv": + # CSV files use UTF-8 encoding + wizard_data["statement_file"] = base64.b64encode(statement_file.encode("utf-8")) + elif csv_or_xls == "xls": + # Excel files are already base64 encoded as ASCII strings + wizard_data["statement_file"] = statement_file.encode("ascii") wizard = self.create(wizard_data) else: wizard = self @@ -148,8 +174,11 @@ def split_base64_excel(self, header_rows_count, rows_per_file_limit): # Get the date column index from the sheet mapping using the parser's method parser = self.env["account.statement.import.sheet.parser"] header = parser.parse_header((input_workbook, input_worksheet), self.sheet_mapping_id) - date_column_indexes = parser._get_column_indexes(header, "timestamp_column", self.sheet_mapping_id) - date_column_index = date_column_indexes[0] if date_column_indexes else None + try: + date_column_indexes = parser._get_column_indexes(header, "timestamp_column", self.sheet_mapping_id) + date_column_index = date_column_indexes[0] if date_column_indexes else None + except Exception as e: + raise UserError(_("Error importing bank statement: %s") % str(e)) # Filter out rows where the date column is empty data_rows = self._filter_rows_with_date(data_rows, date_column_index) From 1e6ea9772d8731f58816668b8162011c6e18e147 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roc=C3=ADo=20Vega?= Date: Sat, 7 Mar 2026 08:09:36 -0300 Subject: [PATCH 08/15] [FIX] account_statement_import: Enhance file import handling for Excel and CSV formats closes ingadhoc/miscellaneous#378 Signed-off-by: Filoquin adhoc --- .../models/account_statement_import.py | 91 +++++++++++++++---- 1 file changed, 75 insertions(+), 16 deletions(-) diff --git a/account_statement_import_sheet_file_bg/models/account_statement_import.py b/account_statement_import_sheet_file_bg/models/account_statement_import.py index a99c9a64..8c869b3d 100644 --- a/account_statement_import_sheet_file_bg/models/account_statement_import.py +++ b/account_statement_import_sheet_file_bg/models/account_statement_import.py @@ -3,13 +3,27 @@ import base64 -from io import BytesIO +import logging +from io import BytesIO, StringIO from markupsafe import Markup from odoo import _, models from odoo.exceptions import UserError from openpyxl import Workbook, load_workbook +_logger = logging.getLogger(__name__) +try: + from csv import reader + + import xlrd +except (OSError, ImportError) as err: # pragma: no cover + _logger.error(err) + +try: + import chardet +except ImportError: + _logger.warning("chardet library not found, please install it from http://pypi.python.org/pypi/chardet") + class AccountStatementImport(models.TransientModel): _name = "account.statement.import" @@ -149,39 +163,84 @@ def import_file_button(self, wizard_data=None): return result def split_base64_excel(self, header_rows_count, rows_per_file_limit): - """Split Excel file into multiple parts to avoid overloading the system. - Returns empty list if file is not a valid Excel or if split is not needed. - Only processes rows where the date column is not empty.""" + """Split Excel/CSV file into multiple parts.""" if not self.statement_file: return [] output_base64_list = [] + mapping = self.sheet_mapping_id + journal = self.env["account.journal"].browse(self.env.context.get("journal_id")) + currency_code = (journal.currency_id or journal.company_id.currency_id).name + try: file_bytes = base64.b64decode(self.statement_file) read_buffer = BytesIO(file_bytes) + + # Try openpyxl (xlsx) input_workbook = load_workbook(read_buffer) input_worksheet = input_workbook.active + all_rows = list(input_worksheet.rows) + csv_or_xlsx = (input_workbook, input_worksheet) + except Exception: - return [self.statement_file] + try: + # Try xlrd (xls) + workbook = xlrd.open_workbook( + file_contents=file_bytes, + encoding_override=(mapping.file_encoding if mapping.file_encoding else None), + ) + sheet = workbook.sheet_by_index(0) + csv_or_xlsx = (workbook, sheet) + + except Exception: + # Try CSV + csv_options = {} + csv_delimiter = mapping._get_column_delimiter_character() + if csv_delimiter: + csv_options["delimiter"] = csv_delimiter + if mapping.quotechar: + csv_options["quotechar"] = mapping.quotechar + + try: + decoded = file_bytes.decode(mapping.file_encoding or "utf-8") + except UnicodeDecodeError: + detected_encoding = chardet.detect(file_bytes).get("encoding", False) + if not detected_encoding: + raise UserError(self.env._("No valid encoding was found for the attached file")) from None + decoded = file_bytes.decode(detected_encoding) + + csv_reader = reader(StringIO(decoded), **csv_options) + csv_or_xlsx = csv_reader + all_rows = [row for row in list(csv_or_xlsx) if any(cell for cell in row)] + parser = self.env["account.statement.import.sheet.parser"] + + # Only parse header and rows for Excel files (when all_rows is not yet populated) + if not all_rows: + header = parser.parse_header(csv_or_xlsx, mapping) + columns = dict() + for column_name in parser._get_column_names(): + columns[column_name] = parser._get_column_indexes(header, column_name, mapping) + data = csv_or_xlsx, self.statement_file + all_rows = parser._parse_rows(mapping, currency_code, data, columns) + else: + # For CSV files, we already have all_rows, convert list to iterator for parse_header + header = parser.parse_header(iter(all_rows), mapping) - all_rows = list(input_worksheet.rows) if not all_rows: return [] header_rows = all_rows[:header_rows_count] data_rows = all_rows[header_rows_count:] - # Get the date column index from the sheet mapping using the parser's method - parser = self.env["account.statement.import.sheet.parser"] - header = parser.parse_header((input_workbook, input_worksheet), self.sheet_mapping_id) try: - date_column_indexes = parser._get_column_indexes(header, "timestamp_column", self.sheet_mapping_id) + date_column_indexes = parser._get_column_indexes(header, "timestamp_column", mapping) date_column_index = date_column_indexes[0] if date_column_indexes else None except Exception as e: raise UserError(_("Error importing bank statement: %s") % str(e)) - # Filter out rows where the date column is empty - data_rows = self._filter_rows_with_date(data_rows, date_column_index) + # Filter rows with empty date + if date_column_index is not None: + data_rows = [r for r in data_rows if len(r) > date_column_index and r[date_column_index]] start_row_index = 0 total_data_rows = len(data_rows) @@ -194,20 +253,20 @@ def split_base64_excel(self, header_rows_count, rows_per_file_limit): output_worksheet = output_workbook.active for header_row in header_rows: - row_values = [cell.value for cell in header_row] - output_worksheet.append(row_values) + output_worksheet.append(header_row) for data_row in rows_for_current_part: - row_values = [cell.value for cell in data_row] - output_worksheet.append(row_values) + output_worksheet.append(data_row) write_buffer = BytesIO() output_workbook.save(write_buffer) output_bytes = write_buffer.getvalue() + base64_content = base64.b64encode(output_bytes).decode("utf-8") output_base64_list.append(base64_content) start_row_index = end_row_index + return output_base64_list def _filter_rows_with_date(self, data_rows, date_column_index): From b4eec5bfc038f588d570451e8c0a5f890312b44f Mon Sep 17 00:00:00 2001 From: Juan Ignacio Carreras Date: Mon, 16 Mar 2026 13:06:07 +0000 Subject: [PATCH 09/15] [FIX]portal_holidays:bypass create closes ingadhoc/miscellaneous#382 Signed-off-by: Filoquin adhoc --- portal_holidays/models/__init__.py | 1 + portal_holidays/models/hr_leave.py | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+) create mode 100644 portal_holidays/models/hr_leave.py diff --git a/portal_holidays/models/__init__.py b/portal_holidays/models/__init__.py index 20fcdb7c..458baf20 100644 --- a/portal_holidays/models/__init__.py +++ b/portal_holidays/models/__init__.py @@ -1 +1,2 @@ +from . import hr_leave from . import hr_leave_allocation diff --git a/portal_holidays/models/hr_leave.py b/portal_holidays/models/hr_leave.py new file mode 100644 index 00000000..65d130be --- /dev/null +++ b/portal_holidays/models/hr_leave.py @@ -0,0 +1,24 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import _, api, models +from odoo.exceptions import AccessError + + +class HolidaysRequest(models.Model): + _inherit = "hr.leave" + + @api.model_create_multi + def create(self, vals_list): + """ + Allows users in the portal holidays group to create leave requests + only for themselves. If the user passes the check, the method runs with + elevated privileges to bypass access restrictions if necessary. + """ + if self.env.user.has_group("portal_holidays.group_portal_backend_holiday"): + user_employee_id = self.env.user.employee_id.id + for vals in vals_list: + if vals.get("employee_id", user_employee_id) != user_employee_id: + raise AccessError(_("You can only create time off requests for yourself.")) + self = self.sudo() + + return super().create(vals_list) From 5abf548c4415c5235e2e65b32243ad86fdeab304 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roc=C3=ADo=20Vega?= Date: Fri, 27 Mar 2026 15:03:11 -0300 Subject: [PATCH 10/15] [FIX] export_bg: Improve export field handling and add external ID for import compatibility when exporting relational fields closes ingadhoc/miscellaneous#388 Signed-off-by: Filoquin adhoc --- export_bg/models/export_bg_mixin.py | 35 ++++++++-------- export_bg/static/src/views/list_controller.js | 41 ++++++++++++++++++- 2 files changed, 58 insertions(+), 18 deletions(-) diff --git a/export_bg/models/export_bg_mixin.py b/export_bg/models/export_bg_mixin.py index 50dee5ca..d61612ed 100644 --- a/export_bg/models/export_bg_mixin.py +++ b/export_bg/models/export_bg_mixin.py @@ -43,16 +43,11 @@ def _export_chunk_bg(self, data, export_id, export_format): # Extract field names considering import_compat mode import_compat = params.get("import_compat", True) - - # For field_names (data extraction), always use the technical field name - # Only use 'value' as fallback when import_compat=True (for import compatibility) + field_names = [f["name"] for f in params["fields"]] if import_compat: - field_names = [f.get("name") or f.get("value") or f.get("id") for f in params["fields"]] - field_labels = field_names # Use field names as headers for import compatibility + field_labels = field_names else: - # When not import_compat, use only 'name' or 'id' for field_names, not 'value' - field_names = [f.get("name") or f.get("id") for f in params["fields"]] - field_labels = [f.get("label") or f.get("string") for f in params["fields"]] + field_labels = [((f.get("label") or "").strip()) for f in params["fields"]] export_data = self.export_data(field_names).get("datas", []) @@ -97,20 +92,28 @@ def web_export(self, data, export_format): The last job combines all chunks into the final export file. """ params = json.loads(data) - Model = self.env[params["model"]].with_context(**params.get("context", {})) + import_compat = params.get("import_compat", True) + Model = self.env[params["model"]].with_context(import_compat=import_compat, **params.get("context", {})) ids = params.get("ids") domain = params.get("domain", []) records = Model.browse(ids) if ids else Model.search(domain) export_id = str(uuid.uuid4()) - return self.env["base.bg"].bg_enqueue_records( - records, - "_export_chunk_bg", - threshold=self.get_export_threshold(), - data=data, - export_id=export_id, - export_format=export_format, + # base.bg serializes its own env context into the job; propagate the + # exact model context used for export so nested relational fields are + # resolved exactly as in synchronous exports. + return ( + self.env["base.bg"] + .with_context(**Model.env.context) + .bg_enqueue_records( + records, + "_export_chunk_bg", + threshold=self.get_export_threshold(), + data=data, + export_id=export_id, + export_format=export_format, + ) ) def _combine_chunks(self, export_id, export_format): diff --git a/export_bg/static/src/views/list_controller.js b/export_bg/static/src/views/list_controller.js index bca98cca..8f755af2 100644 --- a/export_bg/static/src/views/list_controller.js +++ b/export_bg/static/src/views/list_controller.js @@ -1,12 +1,27 @@ /** @odoo-module **/ +import { _t } from "@web/core/l10n/translation"; import { patch } from "@web/core/utils/patch"; import { ListController } from "@web/views/list/list_controller"; +import { ExportDataDialog } from "@web/views/view_dialogs/export_data_dialog"; patch(ListController.prototype, { async downloadExport(fields, import_compat, format) { const resIds = this.isDomainSelected ? false : await this.getSelectedResIds(); const recordCount = resIds ? resIds.length : (this.model.root.count || 0); + const exportedFields = fields.map((field) => ({ + name: field.name || field.id, + label: field.label || field.string, + store: field.store, + type: field.field_type || field.type, + })); + + if (import_compat) { + exportedFields.unshift({ + name: "id", + label: _t("External ID"), + }); + } const threshold = await this.model.orm.call( "ir.model", @@ -17,10 +32,12 @@ patch(ListController.prototype, { if (recordCount > threshold) { const data = { model: this.props.resModel, - fields: fields, + fields: exportedFields, ids: resIds, domain: this.model.root.domain, import_compat: import_compat, + groupby: this.model.root.groupBy, + context: this.props.context, }; const actionResult = await this.model.orm.call( @@ -33,11 +50,31 @@ patch(ListController.prototype, { } ); - if (actionResult && actionResult[0].type === "ir.actions.client") { + if (Array.isArray(actionResult) && actionResult[0]?.type === "ir.actions.client") { this.env.services.action.doAction(actionResult[0]); } + + return { closeWizard: true }; } else { await super.downloadExport(...arguments); } }, + + async onExportData() { + let closeDialog; + const dialogProps = { + context: this.props.context, + defaultExportList: this.defaultExportList, + download: async (...args) => { + const result = await this.downloadExport(...args); + if (result && result.closeWizard && closeDialog) { + closeDialog(); + } + return result; + }, + getExportedFields: this.getExportedFields.bind(this), + root: this.model.root, + }; + closeDialog = this.dialogService.add(ExportDataDialog, dialogProps); + }, }); From f5a5ffc9cf3d98b056f38035f77bfaa6b2c4d4ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roc=C3=ADo=20Vega?= Date: Wed, 1 Apr 2026 11:28:48 -0300 Subject: [PATCH 11/15] [FIX] export_bg: Enhance field extraction for improved export compatibility closes ingadhoc/miscellaneous#391 Signed-off-by: Filoquin adhoc --- export_bg/models/export_bg_mixin.py | 5 +++-- export_bg/static/src/views/list_controller.js | 2 ++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/export_bg/models/export_bg_mixin.py b/export_bg/models/export_bg_mixin.py index d61612ed..3b57855f 100644 --- a/export_bg/models/export_bg_mixin.py +++ b/export_bg/models/export_bg_mixin.py @@ -43,11 +43,12 @@ def _export_chunk_bg(self, data, export_id, export_format): # Extract field names considering import_compat mode import_compat = params.get("import_compat", True) - field_names = [f["name"] for f in params["fields"]] + field_names = [f.get("name") or f.get("value") or f.get("id") for f in params["fields"]] if import_compat: field_labels = field_names else: - field_labels = [((f.get("label") or "").strip()) for f in params["fields"]] + field_names = [f.get("name") or f.get("id") for f in params["fields"]] + field_labels = [f.get("label") or f.get("string") for f in params["fields"]] export_data = self.export_data(field_names).get("datas", []) diff --git a/export_bg/static/src/views/list_controller.js b/export_bg/static/src/views/list_controller.js index 8f755af2..12839eca 100644 --- a/export_bg/static/src/views/list_controller.js +++ b/export_bg/static/src/views/list_controller.js @@ -11,7 +11,9 @@ patch(ListController.prototype, { const recordCount = resIds ? resIds.length : (this.model.root.count || 0); const exportedFields = fields.map((field) => ({ name: field.name || field.id, + value: field.name || field.id, label: field.label || field.string, + string: field.label || field.string, store: field.store, type: field.field_type || field.type, })); From 09812b613e19b8b5a68b7d00c6b8873f6c19ebdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roc=C3=ADo=20Vega?= Date: Thu, 9 Apr 2026 09:25:49 -0300 Subject: [PATCH 12/15] [FIX] account_statement_import_sheet_file_bg: handle OpenPyXL Cell values in sheet header parsing Prevent crash when importing XLSX files where header rows are iterated as OpenPyXL Cell objects instead of plain strings. add parser override in miscellaneous module to normalize header values support both raw strings and Cell instances before calling strip keep OCA module untouched as required preserve existing behavior for CSV/XLS flows Fixes: AttributeError: 'Cell' object has no attribute 'strip' Alternative (more concise) title: closes ingadhoc/miscellaneous#395 Fix: normalize XLSX header cells before strip in bg statement import Signed-off-by: Filoquin adhoc --- .../models/__init__.py | 1 + .../models/account_statement_import.py | 45 ++++++++++--------- .../account_statement_import_sheet_parser.py | 31 +++++++++++++ 3 files changed, 56 insertions(+), 21 deletions(-) create mode 100644 account_statement_import_sheet_file_bg/models/account_statement_import_sheet_parser.py diff --git a/account_statement_import_sheet_file_bg/models/__init__.py b/account_statement_import_sheet_file_bg/models/__init__.py index ae69bca2..fb0170f6 100644 --- a/account_statement_import_sheet_file_bg/models/__init__.py +++ b/account_statement_import_sheet_file_bg/models/__init__.py @@ -1 +1,2 @@ from . import account_statement_import +from . import account_statement_import_sheet_parser diff --git a/account_statement_import_sheet_file_bg/models/account_statement_import.py b/account_statement_import_sheet_file_bg/models/account_statement_import.py index 8c869b3d..8fb99cde 100644 --- a/account_statement_import_sheet_file_bg/models/account_statement_import.py +++ b/account_statement_import_sheet_file_bg/models/account_statement_import.py @@ -32,24 +32,22 @@ class AccountStatementImport(models.TransientModel): def import_file_button(self, wizard_data=None): """Process the file chosen in the wizard, create a bank statement and return a link to its reconciliation page.""" - if not self._context.get("bg_job"): - if self.sheet_mapping_id: - header_column = self.sheet_mapping_id.header_lines_skip_count - # Get row limit from system parameter - rows_limit = ( - self.env["ir.config_parameter"] - .sudo() - .get_param("account_statement_import_sheet_file_bg.rows_per_file_limit") - ) - # Only split if parameter exists and has a valid value - files = [] - if rows_limit: - try: - rows_limit = int(rows_limit) - files = self.split_base64_excel(header_column, rows_limit) - except (ValueError, TypeError): - files = [] + rows_limit = ( + self.env["ir.config_parameter"] + .sudo() + .get_param("account_statement_import_sheet_file_bg.rows_per_file_limit") + ) + if not self._context.get("bg_job") and rows_limit: + # Validate parameter is a valid integer + try: + rows_limit = int(rows_limit) + except (ValueError, TypeError): + # Parameter not valid, skip bg processing + rows_limit = None + if rows_limit and self.sheet_mapping_id: + header_column = self.sheet_mapping_id.header_lines_skip_count + files = self.split_base64_excel(header_column, rows_limit) if files: for idx, file in enumerate(files): # Encode the file to string format, because background jobs cannot @@ -171,7 +169,6 @@ def split_base64_excel(self, header_rows_count, rows_per_file_limit): mapping = self.sheet_mapping_id journal = self.env["account.journal"].browse(self.env.context.get("journal_id")) currency_code = (journal.currency_id or journal.company_id.currency_id).name - try: file_bytes = base64.b64decode(self.statement_file) read_buffer = BytesIO(file_bytes) @@ -179,7 +176,9 @@ def split_base64_excel(self, header_rows_count, rows_per_file_limit): # Try openpyxl (xlsx) input_workbook = load_workbook(read_buffer) input_worksheet = input_workbook.active - all_rows = list(input_worksheet.rows) + # Normalize rows to plain values to keep parser/output logic + # consistent with CSV/xls flows. + all_rows = [[cell.value for cell in row] for row in input_worksheet.rows] csv_or_xlsx = (input_workbook, input_worksheet) except Exception: @@ -277,10 +276,14 @@ def _filter_rows_with_date(self, data_rows, date_column_index): filtered_rows = [] for row in data_rows: + date_value = None + if len(row) > date_column_index: + date_cell_or_value = row[date_column_index] + date_value = date_cell_or_value.value if hasattr(date_cell_or_value, "value") else date_cell_or_value # Check if the row has enough columns and the date column is not empty - if len(row) > date_column_index and row[date_column_index].value: + if len(row) > date_column_index and date_value: filtered_rows.append(row) - elif len(row) > date_column_index and not row[date_column_index].value: + elif len(row) > date_column_index and not date_value: # Stop processing when we find the first empty date break diff --git a/account_statement_import_sheet_file_bg/models/account_statement_import_sheet_parser.py b/account_statement_import_sheet_file_bg/models/account_statement_import_sheet_parser.py new file mode 100644 index 00000000..cfb631a6 --- /dev/null +++ b/account_statement_import_sheet_file_bg/models/account_statement_import_sheet_parser.py @@ -0,0 +1,31 @@ +# Copyright 2026 ADHOC SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, models + + +class AccountStatementImportSheetParser(models.TransientModel): + _inherit = "account.statement.import.sheet.parser" + + @api.model + def parse_header(self, csv_or_xlsx, mapping): + if mapping.no_header: + return [] + + header_line = mapping.header_lines_skip_count + # Prevent negative indexes. + if header_line > 0: + header_line -= 1 + + if isinstance(csv_or_xlsx, tuple): + return super().parse_header(csv_or_xlsx, mapping) + + [next(csv_or_xlsx) for _i in range(header_line)] + header = [] + for value in next(csv_or_xlsx): + raw_value = value.value if hasattr(value, "value") else value + header.append(str(raw_value).strip() if raw_value is not None else "") + + if mapping.offset_column: + header = header[mapping.offset_column :] + return header From 969c9faf803f6f45d14958cf65fe24132ad45be6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CJuan?= Date: Wed, 8 Apr 2026 14:44:42 -0300 Subject: [PATCH 13/15] [IMP]mail_ux: Test email server connection from the UI closes ingadhoc/miscellaneous#394 Signed-off-by: Augusto Weiss --- mail_ux/__init__.py | 1 + mail_ux/__manifest__.py | 3 + mail_ux/models/__init__.py | 3 +- mail_ux/models/ir_mail_server.py | 29 +++++ mail_ux/security/ir.model.access.csv | 2 + mail_ux/views/ir_mail_server_views.xml | 18 +++ mail_ux/wizard/__init__.py | 5 + mail_ux/wizard/mail_server_test_wizard.py | 104 ++++++++++++++++++ .../wizard/mail_server_test_wizard_views.xml | 29 +++++ 9 files changed, 193 insertions(+), 1 deletion(-) create mode 100644 mail_ux/models/ir_mail_server.py create mode 100644 mail_ux/security/ir.model.access.csv create mode 100644 mail_ux/views/ir_mail_server_views.xml create mode 100644 mail_ux/wizard/__init__.py create mode 100644 mail_ux/wizard/mail_server_test_wizard.py create mode 100644 mail_ux/wizard/mail_server_test_wizard_views.xml diff --git a/mail_ux/__init__.py b/mail_ux/__init__.py index d0337769..173091eb 100644 --- a/mail_ux/__init__.py +++ b/mail_ux/__init__.py @@ -3,3 +3,4 @@ # directory ############################################################################## from . import models +from . import wizard diff --git a/mail_ux/__manifest__.py b/mail_ux/__manifest__.py index 0fb49370..2febe4ec 100644 --- a/mail_ux/__manifest__.py +++ b/mail_ux/__manifest__.py @@ -36,6 +36,9 @@ "mail", ], "data": [ + "security/ir.model.access.csv", + "wizard/mail_server_test_wizard_views.xml", + "views/ir_mail_server_views.xml", "views/res_users_views.xml", ], "demo": [], diff --git a/mail_ux/models/__init__.py b/mail_ux/models/__init__.py index dd002ae6..c27049e8 100644 --- a/mail_ux/models/__init__.py +++ b/mail_ux/models/__init__.py @@ -2,6 +2,7 @@ # For copyright and license notices, see __manifest__.py file in module root # directory ############################################################################## +from . import ir_http +from . import ir_mail_server from . import mail_compose_message from . import res_users -from . import ir_http diff --git a/mail_ux/models/ir_mail_server.py b/mail_ux/models/ir_mail_server.py new file mode 100644 index 00000000..82d27a6d --- /dev/null +++ b/mail_ux/models/ir_mail_server.py @@ -0,0 +1,29 @@ +############################################################################## +# For copyright and license notices, see __manifest__.py file in module root +# directory +############################################################################## +from odoo import _, models + + +class IrMailServer(models.Model): + _inherit = "ir.mail_server" + + def action_send_test_mail(self): + """Test the SMTP connection and, if successful, open the test mail wizard. + + Raises the native UserError if the connection test fails so the user + sees the standard Odoo connection-error message. + """ + self.ensure_one() + # Test connection first; any UserError propagates natively to the UI + self.test_smtp_connection() + return { + "type": "ir.actions.act_window", + "name": _("Enviar mail de prueba"), + "res_model": "mail.server.test.wizard", + "view_mode": "form", + "target": "new", + "context": { + "default_mail_server_id": self.id, + }, + } diff --git a/mail_ux/security/ir.model.access.csv b/mail_ux/security/ir.model.access.csv new file mode 100644 index 00000000..1f0ce645 --- /dev/null +++ b/mail_ux/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_mail_server_test_wizard_user,mail.server.test.wizard user,model_mail_server_test_wizard,base.group_system,1,1,1,0 diff --git a/mail_ux/views/ir_mail_server_views.xml b/mail_ux/views/ir_mail_server_views.xml new file mode 100644 index 00000000..f64e7df3 --- /dev/null +++ b/mail_ux/views/ir_mail_server_views.xml @@ -0,0 +1,18 @@ + + + + ir.mail_server.form.send.test + ir.mail_server + + + + + + diff --git a/mail_ux/wizard/__init__.py b/mail_ux/wizard/__init__.py new file mode 100644 index 00000000..6b7de349 --- /dev/null +++ b/mail_ux/wizard/__init__.py @@ -0,0 +1,5 @@ +############################################################################## +# For copyright and license notices, see __manifest__.py file in module root +# directory +############################################################################## +from . import mail_server_test_wizard diff --git a/mail_ux/wizard/mail_server_test_wizard.py b/mail_ux/wizard/mail_server_test_wizard.py new file mode 100644 index 00000000..e31d3f18 --- /dev/null +++ b/mail_ux/wizard/mail_server_test_wizard.py @@ -0,0 +1,104 @@ +import smtplib +import ssl +from socket import gaierror + +from odoo import _, fields, models +from odoo.exceptions import UserError + + +class MailServerTestWizard(models.TransientModel): + _name = "mail.server.test.wizard" + _description = "Wizard de prueba de servidor de correo saliente" + + mail_server_id = fields.Many2one( + comodel_name="ir.mail_server", + string="Servidor de correo saliente", + required=True, + context={"active_test": False}, + ) + email_to = fields.Char( + string="Correo destinatario", + required=True, + ) + + def action_send_test_mail(self): + """Build and send a test email directly via SMTP, bypassing all + Python-level sending guards (server_mode, mail neutralization, etc.). + + The bypass is achieved by calling ``ir.mail_server.connect()`` with + ``allow_archived=True`` and then invoking ``smtp.send_message()`` + directly — never going through ``send_email()``, which is the layer + where restrictions such as ``server_mode.allow_send_mail`` operate. + """ + self.ensure_one() + IrMailServer = self.env["ir.mail_server"] + + # Verify the user has read access on ir.mail_server before elevating. + IrMailServer.check_access_rights("read") + + # Browse with active_test=False so archived servers (neutralized DBs) + # are accessible, but without bypassing ACLs / record rules. + mail_server = IrMailServer.with_context(active_test=False).browse(self.mail_server_id.id) + if not mail_server.exists(): + raise UserError( + _("No se encontró el servidor de correo. " "Por favor, recargue la página e intente de nuevo.") + ) + mail_server.check_access_rule("read") + mail_server = mail_server.sudo() + + email_from = mail_server._get_test_email_from() + message = IrMailServer.build_email( + email_from=email_from, + email_to=[self.email_to], + subject=_("Prueba de mail desde Odoo"), + body=_("Mail de prueba enviado desde mi base de Odoo. " "Por favor no responder."), + subtype="plain", + ) + + smtp = None + try: + # allow_archived=True is critical for neutralized databases where + # all real servers are deactivated by the neutralize process. + smtp = IrMailServer.connect( + mail_server_id=mail_server.id, + allow_archived=True, + ) + if smtp: + smtp.send_message(message) + except (gaierror, TimeoutError) as e: + raise UserError( + _( + "No se pudo enviar el mail: Sin respuesta del servidor. " "Verifique la dirección y el puerto.\n%s", + e, + ) + ) from e + except smtplib.SMTPRecipientsRefused as e: + raise UserError( + _( + "No se pudo enviar el mail: El servidor rechazó la dirección destinataria.\n%s", + e, + ) + ) from e + except smtplib.SMTPException as e: + raise UserError(_("No se pudo enviar el mail: %s", e)) from e + except ssl.SSLError as e: + raise UserError(_("No se pudo enviar el mail: Error de SSL.\n%s", e)) from e + except Exception as e: + raise UserError(_("No se pudo enviar el mail: %s", e)) from e + finally: + if smtp: + try: + smtp.quit() + except Exception: + pass + + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "message": _("¡Mail de prueba enviado con éxito! " "Por favor, revise su casilla de correo."), + "type": "success", + "sticky": False, + "next": {"type": "ir.actions.act_window_close"}, + }, + } diff --git a/mail_ux/wizard/mail_server_test_wizard_views.xml b/mail_ux/wizard/mail_server_test_wizard_views.xml new file mode 100644 index 00000000..80e6f337 --- /dev/null +++ b/mail_ux/wizard/mail_server_test_wizard_views.xml @@ -0,0 +1,29 @@ + + + + mail.server.test.wizard.form + mail.server.test.wizard + +
+ + + + +
+
+
+
+
+
From c7afeb3ecaaf7c8f292184b97756bc30dcf20bf0 Mon Sep 17 00:00:00 2001 From: roboadhoc Date: Fri, 17 Apr 2026 13:42:29 +0000 Subject: [PATCH 14/15] [BOT] Bump version: mail_ux 18.0.1.4.0 Merged: ingadhoc/miscellaneous#394 --- mail_ux/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mail_ux/__manifest__.py b/mail_ux/__manifest__.py index 2febe4ec..9980c4cb 100644 --- a/mail_ux/__manifest__.py +++ b/mail_ux/__manifest__.py @@ -19,7 +19,7 @@ ############################################################################## { "name": "Mail UX", - "version": "18.0.1.3.0", + "version": "18.0.1.4.0", "category": "Base", "sequence": 14, "summary": "", From 8dce76a9220667566be9bb2a88419bd34864ecec Mon Sep 17 00:00:00 2001 From: Felipe Garcia Suez Date: Mon, 20 Apr 2026 09:49:59 -0300 Subject: [PATCH 15/15] [ADD] account_reconcile_bg: background processing for large bank reconciliations Implements background job processing for bank reconciliations when dealing with large payment batches to prevent timeouts and improve user experience. Main features: - Automatic background processing for large reconciliations (configurable threshold) - User notifications when background reconciliation completes - Seamless integration with existing bank reconciliation workflow - No UI blocking: users can continue working while large batches process Technical implementation: - Override bank.rec.widget._js_action_validate() to detect large reconciliations - Use base_bg to enqueue reconciliation jobs - Add reconciliation_in_background flag to account.bank.statement.line - Configurable via system parameter: account_reconcile_bg.lines_threshold Includes comprehensive testing guide and unit tests. closes ingadhoc/miscellaneous#400 Signed-off-by: rov-adhoc --- account_reconcile_bg/README.rst | 81 ++++++++++++ account_reconcile_bg/__init__.py | 2 + account_reconcile_bg/__manifest__.py | 37 ++++++ .../data/ir_config_parameter_data.xml | 10 ++ account_reconcile_bg/models/__init__.py | 5 + .../models/account_bank_statement_line.py | 84 ++++++++++++ .../models/account_move_line.py | 16 +++ .../models/bank_rec_widget.py | 120 ++++++++++++++++++ account_reconcile_bg/models/bg_job.py | 42 ++++++ account_reconcile_bg/tests/__init__.py | 2 + .../tests/test_account_reconcile_bg.py | 91 +++++++++++++ 11 files changed, 490 insertions(+) create mode 100644 account_reconcile_bg/README.rst create mode 100644 account_reconcile_bg/__init__.py create mode 100644 account_reconcile_bg/__manifest__.py create mode 100644 account_reconcile_bg/data/ir_config_parameter_data.xml create mode 100644 account_reconcile_bg/models/__init__.py create mode 100644 account_reconcile_bg/models/account_bank_statement_line.py create mode 100644 account_reconcile_bg/models/account_move_line.py create mode 100644 account_reconcile_bg/models/bank_rec_widget.py create mode 100644 account_reconcile_bg/models/bg_job.py create mode 100644 account_reconcile_bg/tests/__init__.py create mode 100644 account_reconcile_bg/tests/test_account_reconcile_bg.py diff --git a/account_reconcile_bg/README.rst b/account_reconcile_bg/README.rst new file mode 100644 index 00000000..95efee3c --- /dev/null +++ b/account_reconcile_bg/README.rst @@ -0,0 +1,81 @@ +Account Reconcile Background +============================ + +This module enables background processing for bank reconciliation operations when dealing with large payment batches, preventing timeouts and improving user experience. + +**Table of contents** + +.. contents:: + :local: + +Overview +======== + +When reconciling large payment batches (e.g., multiple payments included in a single batch) with a bank statement line, the operation can take a long time and may cause timeouts. This module solves this problem by automatically processing large reconciliations in the background, allowing users to continue working while the reconciliation completes. + +Features +======== + +* **Automatic Background Processing**: Large reconciliations are automatically sent to background processing +* **Configurable Threshold**: System parameter to control when background processing kicks in (default: 50 lines) +* **User Notifications**: Users receive notifications when background reconciliation completes +* **No UI Blocking**: Users can continue reconciling other transactions while large batches process +* **Seamless Integration**: Works transparently with existing bank reconciliation workflow + +How It Works +============ + +The module monitors the number of lines being reconciled in the bank reconciliation widget: + +1. When validating a reconciliation, it counts the number of source lines +2. If the count is **below the threshold** (default 50), the reconciliation proceeds normally (synchronous) +3. If the count is **above the threshold**, the reconciliation is enqueued as a background job +4. The user receives an immediate success notification and can continue working +5. When the background job completes, the user is notified via internal message + +Configuration +============= + +The threshold for background processing can be configured via system parameters: + +* Navigate to **Settings > Technical > Parameters > System Parameters** +* Find or create the parameter ``account_reconcile_bg.lines_threshold`` +* Default value: ``50`` +* Set to a higher value to process larger reconciliations synchronously +* Set to a lower value to send more reconciliations to background + +Technical Details +================= + +Dependencies +------------ + +* ``account_accountant``: Odoo Enterprise accounting module with bank reconciliation +* ``base_bg``: Background job processing system + +Model Inheritance +----------------- + +The module inherits from ``bank.rec.widget`` and overrides: + +* ``_js_action_validate()``: Detects large reconciliations and routes to background +* ``_validate_in_background()``: Enqueues the job using base_bg +* ``_do_validate()``: Executes the actual validation in background + +Credits +======= + +Authors +------- + +* ADHOC SA + +Contributors +------------ + +* ADHOC SA + +Maintainers +----------- + +This module is maintained by ADHOC SA. diff --git a/account_reconcile_bg/__init__.py b/account_reconcile_bg/__init__.py new file mode 100644 index 00000000..3275ac2a --- /dev/null +++ b/account_reconcile_bg/__init__.py @@ -0,0 +1,2 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from . import models diff --git a/account_reconcile_bg/__manifest__.py b/account_reconcile_bg/__manifest__.py new file mode 100644 index 00000000..7d7698b3 --- /dev/null +++ b/account_reconcile_bg/__manifest__.py @@ -0,0 +1,37 @@ +############################################################################## +# +# Copyright (C) 2026 ADHOC SA (http://www.adhoc.com.ar) +# All Rights Reserved. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +############################################################################## +{ + "name": "Account Reconcile Background", + "version": "18.0.1.0.0", + "category": "Accounting", + "author": "ADHOC SA", + "website": "https://www.adhoc.com.ar", + "license": "AGPL-3", + "summary": "Process bank reconciliation in background for large payment batches", + "depends": [ + "account_accountant", + "base_bg", + ], + "data": [ + "data/ir_config_parameter_data.xml", + ], + "installable": True, + "auto_install": False, +} diff --git a/account_reconcile_bg/data/ir_config_parameter_data.xml b/account_reconcile_bg/data/ir_config_parameter_data.xml new file mode 100644 index 00000000..b24cd47f --- /dev/null +++ b/account_reconcile_bg/data/ir_config_parameter_data.xml @@ -0,0 +1,10 @@ + + + + + + account_reconcile_bg.lines_threshold + 100 + + + diff --git a/account_reconcile_bg/models/__init__.py b/account_reconcile_bg/models/__init__.py new file mode 100644 index 00000000..da979872 --- /dev/null +++ b/account_reconcile_bg/models/__init__.py @@ -0,0 +1,5 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from . import bank_rec_widget +from . import account_bank_statement_line +from . import account_move_line +from . import bg_job diff --git a/account_reconcile_bg/models/account_bank_statement_line.py b/account_reconcile_bg/models/account_bank_statement_line.py new file mode 100644 index 00000000..4e4102c7 --- /dev/null +++ b/account_reconcile_bg/models/account_bank_statement_line.py @@ -0,0 +1,84 @@ +############################################################################## +# For copyright and license notices, see __manifest__.py file in module root +# directory +############################################################################## +import logging + +from markupsafe import Markup +from odoo import _, api, fields, models +from odoo.exceptions import UserError + + +class AccountBankStatementLine(models.Model): + _inherit = "account.bank.statement.line" + + reconciliation_in_background = fields.Boolean( + string="Reconciliation in Background", + default=False, + readonly=True, + help="Indicates that this line is being reconciled in background", + ) + + def _bg_validate_reconciliation(self, selected_aml_ids=None): + """ + Método ejecutado en background para validar la conciliación. + Se llama desde el job de base_bg. + + :param selected_aml_ids: IDs de las líneas seleccionadas por el usuario + """ + self.ensure_one() + _logger = logging.getLogger(__name__) + + # Preparar datos para mensaje + base_url = self.env["ir.config_parameter"].sudo().get_param("web.base.url") + st_line_url = f"{base_url}/odoo/account.bank.statement.line/{self.id}" + st_line_name = self.name or f"Line {self.id}" + + try: + # Crear el widget de conciliación + wizard = self.env["bank.rec.widget"].with_context(default_st_line_id=self.id).new({}) + + _logger.info(f"[BG] Wizard created for st_line {self.id}") + + # Agregar las líneas al widget correctamente usando el método interno + if selected_aml_ids: + amls = self.env["account.move.line"].browse(selected_aml_ids) + wizard._action_add_new_amls(amls, allow_partial=False) + + # Ejecutar la validación con el context manager + with wizard._action_validate_method(): + wizard._action_validate() + + # Retornar mensaje de éxito + return Markup( + _("Bank reconciliation completed successfully:
%s") + % (st_line_url, st_line_name) + ) + except Exception as e: + return Markup( + _("Bank reconciliation failed:
%s

Error: %s") + % (st_line_url, st_line_name, str(e)) + ) + finally: + self.write({"reconciliation_in_background": False}) + + @api.constrains( + "amount", + "amount_currency", + "currency_id", + ) + def _check_reconciliation_in_background(self): + """Valida que no se modifiquen líneas en proceso de conciliación background.""" + if self.env.context.get("bg_job"): + return + for line in self: + if line.reconciliation_in_background: + raise UserError( + _( + "Cannot modify payment lines that are being reconciled in background. " + "Please wait until the reconciliation process is complete.\n" + "Journal Entry (id): %(entry)s (%(id)s)", + entry=line.move_id.name, + id=line.move_id.id, + ) + ) diff --git a/account_reconcile_bg/models/account_move_line.py b/account_reconcile_bg/models/account_move_line.py new file mode 100644 index 00000000..17d33f13 --- /dev/null +++ b/account_reconcile_bg/models/account_move_line.py @@ -0,0 +1,16 @@ +############################################################################## +# For copyright and license notices, see __manifest__.py file in module root +# directory +############################################################################## +from odoo import fields, models + + +class AccountMoveLine(models.Model): + _inherit = "account.move.line" + + reconciliation_in_background = fields.Boolean( + string="Reconciliation in Background", + default=False, + readonly=True, + help="Indicates that this line is being reconciled in background", + ) diff --git a/account_reconcile_bg/models/bank_rec_widget.py b/account_reconcile_bg/models/bank_rec_widget.py new file mode 100644 index 00000000..1fb4f9fb --- /dev/null +++ b/account_reconcile_bg/models/bank_rec_widget.py @@ -0,0 +1,120 @@ +############################################################################## +# For copyright and license notices, see __manifest__.py file in module root +# directory +############################################################################## +import logging + +from odoo import _, models +from odoo.exceptions import UserError + + +class BankRecWidget(models.Model): + _inherit = "bank.rec.widget" + + def _js_action_validate(self): + """ + Override para procesar conciliaciones grandes en background. + Si hay muchas líneas (> threshold), usa base_bg para procesarlo en 2do plano. + """ + self.ensure_one() + + # Verificar si ya está procesando en background + if self.st_line_id.reconciliation_in_background: + raise UserError( + _("This reconciliation is already being processed in background. Please wait until it finishes.") + ) + + # Invalidar caché y refrescar para obtener el estado actual desde la BD + self.selected_aml_ids.invalidate_recordset(fnames=["reconciliation_in_background"]) + + # Verificar si alguna de las líneas seleccionadas ya está en background (en cualquier extracto) + lines_in_bg = self.selected_aml_ids.filtered("reconciliation_in_background") + if lines_in_bg: + raise UserError( + _( + "Some of the selected payment lines (%s) are already being reconciled in background on another statement. " + "Please wait until they finish or select different lines." + ) + % len(lines_in_bg) + ) + + # Obtener el umbral de líneas desde parámetros del sistema (default: 50) + threshold = int(self.env["ir.config_parameter"].sudo().get_param("account_reconcile_bg.lines_threshold", "50")) + + # Contar las líneas seleccionadas para conciliar + lines_count = len(self.selected_aml_ids) + + # DEBUG: Log para verificar + + # Si hay pocas líneas, ejecutar el proceso normal de manera sincrónica + if lines_count < threshold: + return super()._js_action_validate() + + # Si hay muchas líneas, procesar en background + return self._validate_in_background() + + def _validate_in_background(self): + """ + Encola la validación de conciliación en background usando base_bg. + Nota: Como bank.rec.widget no se persiste, encolamos usando st_line_id. + """ + self.ensure_one() + + _logger = logging.getLogger(__name__) + + # Marcar la línea de extracto y las líneas de pago como procesando en background + self.st_line_id.write({"reconciliation_in_background": True}) + self.selected_aml_ids.write({"reconciliation_in_background": True}) + + # Flush para asegurar que los cambios se escriben inmediatamente en la BD + # Esto previene condiciones de carrera donde otro usuario podría conciliar las mismas líneas + self.env.flush_all() + + # Capturar los IDs antes de encolar + selected_ids = self.selected_aml_ids.ids + _logger.info(f"[account_reconcile_bg] Capturing selected_aml_ids: {selected_ids}") + + try: + # Encolar el job usando la línea de extracto (modelo persistente) + _action, _jobs = self.env["base.bg"].bg_enqueue_records( + self.st_line_id, + "_bg_validate_reconciliation", + threshold=1, # Un job por línea + name=_("Bank Reconciliation: %s") % self.st_line_id.name, + priority=5, # Alta prioridad + selected_aml_ids=selected_ids, # Pasar solo los IDs (lista de enteros) + ) + _logger.info("[account_reconcile_bg] Job enqueued successfully") + except Exception: + # Si falla al encolar, limpiar los flags + self.st_line_id.write({"reconciliation_in_background": False}) + self.selected_aml_ids.write({"reconciliation_in_background": False}) + raise + + # Enviar notificación al usuario usando el bus + self.env["bus.bus"]._sendone( + self.env.user.partner_id, + "simple_notification", + { + "type": "success", + "message": _( + "This reconciliation is being processed in background. You will be notified when it's done." + ), + }, + ) + + # Configurar el comando para el widget + self.return_todo_command = {"done": True} + + # Retornar vacío - el widget usa return_todo_command + return + + def _do_validate(self): + """ + Método que ejecuta la validación real en background. + Se llama desde el job de base_bg. + """ + self.ensure_one() + # Ejecutar la validación usando el método context manager + with self._action_validate_method(): + self._action_validate() diff --git a/account_reconcile_bg/models/bg_job.py b/account_reconcile_bg/models/bg_job.py new file mode 100644 index 00000000..c6e4a321 --- /dev/null +++ b/account_reconcile_bg/models/bg_job.py @@ -0,0 +1,42 @@ +############################################################################## +# For copyright and license notices, see __manifest__.py file in module root +# directory +############################################################################## +from odoo import models + + +class BgJob(models.Model): + _inherit = "bg.job" + + def cancel(self, message: str | None = None): + """Override para limpiar el flag cuando se cancela el job.""" + res = super().cancel(message=message) + self.filtered( + lambda j: j.model == "account.bank.statement.line" and j.method == "_bg_validate_reconciliation" + )._clean_reconciliation_flag() + return res + + def fail(self, error_message: str): + """Override para limpiar el flag cuando falla el job.""" + res = super().fail(error_message) + self.filtered( + lambda j: j.model == "account.bank.statement.line" and j.method == "_bg_validate_reconciliation" + )._clean_reconciliation_flag() + return res + + def _clean_reconciliation_flag(self): + """Limpia el flag reconciliation_in_background para jobs de conciliación.""" + for job in self: + kwargs = job.kwargs_json or {} + # Limpiar flag de la línea de extracto + record_ids = kwargs.get("_record_ids", []) + if record_ids: + lines = self.env["account.bank.statement.line"].browse(record_ids).exists() + if lines: + lines.write({"reconciliation_in_background": False}) + # Limpiar flag de las líneas de pago (solo las que existen) + selected_aml_ids = kwargs.get("selected_aml_ids", []) + if selected_aml_ids: + amls = self.env["account.move.line"].browse(selected_aml_ids).exists() + if amls: + amls.write({"reconciliation_in_background": False}) diff --git a/account_reconcile_bg/tests/__init__.py b/account_reconcile_bg/tests/__init__.py new file mode 100644 index 00000000..e414fa7e --- /dev/null +++ b/account_reconcile_bg/tests/__init__.py @@ -0,0 +1,2 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from . import test_account_reconcile_bg diff --git a/account_reconcile_bg/tests/test_account_reconcile_bg.py b/account_reconcile_bg/tests/test_account_reconcile_bg.py new file mode 100644 index 00000000..5f8c1cc0 --- /dev/null +++ b/account_reconcile_bg/tests/test_account_reconcile_bg.py @@ -0,0 +1,91 @@ +############################################################################## +# For copyright and license notices, see __manifest__.py file in module root +# directory +############################################################################## +from odoo.addons.account_accountant.tests.test_bank_rec_widget_common import ( + TestBankRecWidgetCommon, +) +from odoo.tests import tagged + + +@tagged("post_install", "-at_install") +class TestAccountReconcileBg(TestBankRecWidgetCommon): + """Test que la conciliación se envía a background cuando hay muchas líneas.""" + + def _create_test_invoices(self, count=10): + """Crea facturas de prueba para conciliar.""" + invoices = self.env["account.move"] + for i in range(count): + invoice = self._create_invoice_line( + "out_invoice", + invoice_line_ids=[{"price_unit": 100.0}], + ) + invoices |= invoice.move_id + return invoices + + def test_sync_below_threshold(self): + """Con pocas líneas (< threshold) debe procesar sincrónico.""" + self.env["ir.config_parameter"].sudo().set_param("account_reconcile_bg.lines_threshold", "3") + + # Crear facturas y línea de extracto (2 < 3 = sync) + invoices = self._create_test_invoices(count=2) + st_line = self._create_st_line(amount=200.0) + + # Contar jobs antes + jobs_before = self.env["bg.job"].search_count([]) + + # Crear widget y seleccionar facturas + wizard = self.env["bank.rec.widget"].with_context(default_st_line_id=st_line.id).new({}) + + # Simular selección de líneas + invoice_lines = invoices.line_ids.filtered(lambda l: l.account_id.account_type == "asset_receivable") + wizard.selected_aml_ids = invoice_lines + + # Validar NO debe crear job (2 < 3) + jobs_after = self.env["bg.job"].search_count([]) + self.assertEqual(jobs_before, jobs_after, "No debe crear jobs en sync") + + def test_background_above_threshold(self): + """Con muchas líneas (>= threshold) debe ir a background y ejecutar correctamente.""" + self.env["ir.config_parameter"].sudo().set_param("account_reconcile_bg.lines_threshold", "2") + + # Crear facturas y línea de extracto (3 >= 2 = background) + invoices = self._create_test_invoices(count=3) + st_line = self._create_st_line(amount=300.0) + + # Crear widget y seleccionar facturas + wizard = self.env["bank.rec.widget"].with_context(default_st_line_id=st_line.id).new({}) + invoice_lines = invoices.line_ids.filtered(lambda l: l.account_id.account_type == "asset_receivable") + wizard.selected_aml_ids = invoice_lines + + # Validar - debe crear job + wizard._js_action_validate() + + # Buscar el job creado (por modelo, método y orden por fecha) + job = self.env["bg.job"].search( + [ + ("model", "=", "account.bank.statement.line"), + ("method", "=", "_bg_validate_reconciliation"), + ], + order="create_date desc", + limit=1, + ) + self.assertTrue(job, "Debe crear un job en background") + self.assertEqual(job.state, "enqueued", "El job debe estar encolado") + self.assertTrue(st_line.reconciliation_in_background, "El flag debe estar activo") + + # Ejecutar el método directamente simulando el contexto que setea bg.job.run() + selected_aml_ids = job.kwargs_json.get("selected_aml_ids", []) + st_line.with_context(bg_job=True, bg_job_id=job.id)._bg_validate_reconciliation( + selected_aml_ids=selected_aml_ids + ) + + # Verificar que el flag se limpió + self.assertFalse(st_line.reconciliation_in_background, "El flag debe estar en False al terminar") + + # Verificar que la línea de extracto está conciliada + self.assertTrue(st_line.is_reconciled, "La línea debe estar conciliada") + + # Verificar que las facturas están conciliadas + for invoice in invoices: + self.assertEqual(invoice.payment_state, "paid", f"La factura {invoice.name} debe estar pagada")