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/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 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 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") 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 e2b004bb..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 @@ -3,12 +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" @@ -17,33 +32,46 @@ 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 + # 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 +114,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 @@ -123,36 +161,85 @@ 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 + # 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: - 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) - 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", 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) @@ -165,20 +252,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): @@ -189,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 diff --git a/export_bg/models/export_bg_mixin.py b/export_bg/models/export_bg_mixin.py index 234f6041..3b57855f 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) @@ -39,8 +41,14 @@ 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.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"]] + if import_compat: + field_labels = field_names + else: + 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", []) @@ -85,20 +93,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): @@ -129,7 +145,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() diff --git a/export_bg/static/src/views/list_controller.js b/export_bg/static/src/views/list_controller.js index bca98cca..12839eca 100644 --- a/export_bg/static/src/views/list_controller.js +++ b/export_bg/static/src/views/list_controller.js @@ -1,12 +1,29 @@ /** @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, + value: field.name || field.id, + label: field.label || field.string, + string: 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 +34,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 +52,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); + }, }); 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..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": "", @@ -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 + +
+ + + + + +
+
+
+
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) 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