Compare commits

...
Sign in to create a new pull request.

28 commits

Author SHA1 Message Date
3be3de173e add partner_email_sync_portal_login 2026-10-06 12:10:26 +02:00
3c5809774f website_search_products: fix redirect after login 2026-10-05 18:34:47 +02:00
9d69e5f095 add website_decimal_quantity 2026-10-02 10:05:22 +02:00
0323b6672e hr_timesheet_sheet* fixes: back to list after remove a timesheet. bulk unfreeze confirmed and pending sheets 2026-10-01 09:38:17 +02:00
8b0d0bb6cc add hr_timesheet_sheet_no_review, hr_timesheet_sheet_holidays, hr_timesheet_sheet_delete_cascade 2026-09-22 10:58:36 +02:00
b07082f70d add stock_account_avco_negative_fix 2026-08-03 13:11:10 +02:00
10a288f8d5 Merge pull request 'Actualizar product_sale_price_from_pricelist/models/product_pricelist.py' (#17) from pricelist_qty into 16.0
Reviewed-on: #17
2026-08-03 07:26:28 +00:00
snt
99dc5f3848 Actualizar product_sale_price_from_pricelist/models/product_pricelist.py 2026-07-31 09:17:42 +00:00
08e34c1426 add payment_redsys_preauth 2026-06-24 11:49:41 +02:00
0f2c8c0565 add project_task_analytic_account_sync 2026-06-16 10:56:18 +02:00
5247938b86 add spreadsheet_dashboard_pos_cooperative & pos_dashboard_cooperative 2026-06-05 19:55:00 +02:00
86101218e9 pos_cashdro_allow_manual: move config from paymet.method to res.config 2026-04-28 10:47:43 +02:00
d994ab600e pos_cashdro_refund: fix change lines 2026-04-27 13:26:03 +02:00
b7e8f8967c add pos_cashdro_refund,pos_cashdro_allow_manual 2026-04-24 13:34:06 +02:00
9b25650118 remove pos_require_product_quantity already in OCA 2026-04-24 10:39:23 +02:00
7f43afafdf remove pos_hide_partner_info already in OCA 2026-04-24 10:36:27 +02:00
611979ea2e pos_hide_partnerlist: hide details (edit-partner-button) button 2026-04-24 10:33:34 +02:00
c76254e3c7 purchase_order_supplierinfo_update_triple_discount: update discounts on write 2026-04-13 13:53:23 +02:00
489eefefc7 product_sale_price_from_pricelist: add rounding method to compute theroritical price 2026-04-09 12:48:23 +02:00
f775050ea8 purchase_order_product_recommendation_supermarket: add filter products by main supplier 2026-04-07 11:47:35 +02:00
524e3d643c product_sale_price_from_pricelist: allow to configure rounding of list_price_theoritical after taxes. Add the configured pricelist margin in product form view 2026-04-02 14:23:36 +02:00
91971ae56b add purchase_order_supplierinfo_update_triple_discount 2026-04-02 13:02:18 +02:00
f586c99df9 add purchase_price_review_status 2026-04-02 12:43:36 +02:00
d9a0eeb878 [16.0] add purchase_report_received_amount 2026-03-09 17:09:55 +01:00
bf02699b61 FoodcoopBCN/foodcoopbcn#167 add pos_product_available_link 2026-02-24 13:37:26 +01:00
dab4123379 purchase_order_product_recommendation_supermarket: fix round problem in _compute_packaging_qty 2026-02-17 17:48:09 +01:00
25faab83bb Criptomart/red-supermercados-coop#37 purchase_order_product_recommendation_supermarket: round qty_to_order when product_uom_categ_unit 2026-02-17 17:36:13 +01:00
a1f73dabe7 Arbore/arbore#85 add mail_quoted_reply_icon 2026-02-14 17:34:18 +01:00
290 changed files with 17260 additions and 1164 deletions

View file

@ -0,0 +1,2 @@
from . import models
from . import wizards

View file

@ -0,0 +1,25 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
{
"name": "HR Timesheet Sheet - Delete Cascade",
"version": "16.0.1.0.0",
"category": "Human Resources",
"summary": "Delete a timesheet sheet along with its timesheet lines,"
" with confirmation",
"author": "Criptomart, Odoo Community Association (OCA)",
"website": "https://github.com/OCA/timesheet",
"license": "AGPL-3",
"development_status": "Beta",
"depends": ["hr_timesheet_sheet_holidays", "sale_timesheet"],
"data": [
"security/ir.model.access.csv",
"security/hr_timesheet_sheet_security.xml",
"wizards/delete_sheet_wizard_views.xml",
"views/hr_timesheet_sheet_views.xml",
"views/res_config_settings_views.xml",
],
"installable": True,
"auto_install": False,
"application": False,
}

View file

@ -0,0 +1,333 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * hr_timesheet_sheet_delete_cascade
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 16.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-09-15 08:20+0000\n"
"PO-Revision-Date: 2026-09-15 08:20+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Language: es\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#. module: hr_timesheet_sheet_delete_cascade
#. odoo-python
#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0
#, python-format
msgid "%(count)s entries, %(hours)s hours in total."
msgstr "%(count)s partes, %(hours)s horas en total."
#. module: hr_timesheet_sheet_delete_cascade
#. odoo-python
#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0
#, python-format
msgid "%(employee)s - %(type)s (%(date_from)s - %(date_to)s): %(days)s days"
msgstr "%(employee)s - %(type)s (%(date_from)s - %(date_to)s): %(days)s días"
#. module: hr_timesheet_sheet_delete_cascade
#. odoo-python
#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0
#, python-format
msgid "%s days will be restored to the employee balances."
msgstr "Se devolverán %s días al saldo de los empleados."
#. module: hr_timesheet_sheet_delete_cascade
#. odoo-python
#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0
#, python-format
msgid "%s time off entries will be detached from the sheet and will survive the deletion."
msgstr "%s partes de ausencia se desvincularán de la hoja y sobrevivirán al borrado."
#. module: hr_timesheet_sheet_delete_cascade
#: model_terms:ir.ui.view,arch_db:hr_timesheet_sheet_delete_cascade.res_config_settings_view_form
msgid ""
"<span class=\"fa fa-lg fa-building\" title=\"Values set here are company-"
"specific.\" aria-label=\"Values set here are company-specific.\" "
"groups=\"base.group_multi_company\" role=\"img\"/>"
msgstr ""
#. module: hr_timesheet_sheet_delete_cascade
#: model:ir.model,name:hr_timesheet_sheet_delete_cascade.model_account_analytic_line
msgid "Analytic Line"
msgstr "Línea analítica"
#. module: hr_timesheet_sheet_delete_cascade
#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__blocking_reason
msgid "Blocking Reason"
msgstr "Motivo del bloqueo"
#. module: hr_timesheet_sheet_delete_cascade
#: model_terms:ir.ui.view,arch_db:hr_timesheet_sheet_delete_cascade.hr_timesheet_sheet_delete_wizard_form
msgid "Cancel"
msgstr "Cancelar"
#. module: hr_timesheet_sheet_delete_cascade
#: model:ir.model.fields.selection,name:hr_timesheet_sheet_delete_cascade.selection__hr_timesheet_sheet_delete_wizard__leave_policy__cancel
#: model:ir.model.fields.selection,name:hr_timesheet_sheet_delete_cascade.selection__res_company__timesheet_sheet_delete_leave_policy__cancel
msgid "Cancel the time off and restore the balance"
msgstr "Anular la ausencia y devolver los días al saldo"
#. module: hr_timesheet_sheet_delete_cascade
#: model_terms:ir.ui.view,arch_db:hr_timesheet_sheet_delete_cascade.res_config_settings_view_form
msgid ""
"Choose what happens to the time off entries contained\n"
" in a timesheet sheet when the sheet is deleted. Keeping\n"
" the time off leaves the request untouched and only\n"
" detaches its entries from the sheet. Cancelling the time\n"
" off refuses and deletes the whole request and restores\n"
" the days to the employee balance, including the days\n"
" that fall outside the deleted sheet: it cannot be\n"
" undone."
msgstr ""
"Elige qué pasa con los partes de ausencia contenidos en una\n"
" hoja de servicio cuando se borra la hoja. Conservar la\n"
" ausencia la deja intacta y solo desvincula sus partes de\n"
" la hoja. Anular la ausencia rechaza y borra la ausencia\n"
" entera y devuelve los días al saldo del empleado,\n"
" incluidos los que caen fuera de la hoja borrada: no se\n"
" puede deshacer."
#. module: hr_timesheet_sheet_delete_cascade
#: model:ir.model,name:hr_timesheet_sheet_delete_cascade.model_res_company
msgid "Companies"
msgstr "Compañías"
#. module: hr_timesheet_sheet_delete_cascade
#: model:ir.model,name:hr_timesheet_sheet_delete_cascade.model_res_config_settings
msgid "Config Settings"
msgstr "Ajustes de configuración"
#. module: hr_timesheet_sheet_delete_cascade
#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__create_uid
msgid "Created by"
msgstr "Creado por"
#. module: hr_timesheet_sheet_delete_cascade
#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__create_date
msgid "Created on"
msgstr "Creado el"
#. module: hr_timesheet_sheet_delete_cascade
#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__leave_days
msgid "Days Restored"
msgstr "Días devueltos"
#. module: hr_timesheet_sheet_delete_cascade
#: model_terms:ir.ui.view,arch_db:hr_timesheet_sheet_delete_cascade.hr_timesheet_sheet_form
msgid "Delete Sheet"
msgstr "Borrar hoja"
#. module: hr_timesheet_sheet_delete_cascade
#. odoo-python
#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0
#, python-format
msgid "Delete Timesheet Sheet"
msgstr "Borrar hoja de servicio"
#. module: hr_timesheet_sheet_delete_cascade
#: model:ir.actions.act_window,name:hr_timesheet_sheet_delete_cascade.action_hr_timesheet_sheet_delete_wizard
#: model:ir.model,name:hr_timesheet_sheet_delete_cascade.model_hr_timesheet_sheet_delete_wizard
#: model_terms:ir.ui.view,arch_db:hr_timesheet_sheet_delete_cascade.hr_timesheet_sheet_delete_wizard_form
msgid "Delete Timesheet Sheets"
msgstr "Borrar hojas de servicio"
#. module: hr_timesheet_sheet_delete_cascade
#: model_terms:ir.ui.view,arch_db:hr_timesheet_sheet_delete_cascade.hr_timesheet_sheet_delete_wizard_form
msgid "Delete permanently"
msgstr "Borrar definitivamente"
#. module: hr_timesheet_sheet_delete_cascade
#. odoo-python
#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0
#, python-format
msgid "Deleting a timesheet sheet also deletes its timesheet entries and cannot be undone. Use the Delete Sheet button on the sheet to review what will be deleted and confirm it."
msgstr "Borrar una hoja de servicio borra también sus partes de horas y no se puede deshacer. Usa el botón Borrar hoja de la propia hoja para revisar qué se va a borrar y confirmarlo."
#. module: hr_timesheet_sheet_delete_cascade
#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__display_name
msgid "Display Name"
msgstr "Nombre mostrado"
#. module: hr_timesheet_sheet_delete_cascade
#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__timesheet_hours
msgid "Hours"
msgstr "Horas"
#. module: hr_timesheet_sheet_delete_cascade
#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__id
msgid "ID"
msgstr "ID"
#. module: hr_timesheet_sheet_delete_cascade
#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__invoiced_count
msgid "Invoiced Entries"
msgstr "Partes facturados"
#. module: hr_timesheet_sheet_delete_cascade
#: model:ir.model.fields.selection,name:hr_timesheet_sheet_delete_cascade.selection__hr_timesheet_sheet_delete_wizard__leave_policy__detach
#: model:ir.model.fields.selection,name:hr_timesheet_sheet_delete_cascade.selection__res_company__timesheet_sheet_delete_leave_policy__detach
msgid "Keep the time off, detach its lines"
msgstr "Conservar la ausencia y desvincular sus líneas"
#. module: hr_timesheet_sheet_delete_cascade
#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard____last_update
msgid "Last Modified on"
msgstr "Última modificación el"
#. module: hr_timesheet_sheet_delete_cascade
#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__write_uid
msgid "Last Updated by"
msgstr "Última actualización por"
#. module: hr_timesheet_sheet_delete_cascade
#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__write_date
msgid "Last Updated on"
msgstr "Última actualización el"
#. module: hr_timesheet_sheet_delete_cascade
#. odoo-python
#: code:addons/hr_timesheet_sheet_delete_cascade/wizards/delete_sheet_wizard.py:0
#, python-format
msgid "No timesheet sheet was selected."
msgstr "No se ha seleccionado ninguna hoja de servicio."
#. module: hr_timesheet_sheet_delete_cascade
#. odoo-python
#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0
#, python-format
msgid "Sheet %(sheet)s contains %(count)s invoiced timesheet entries, which can never be deleted."
msgstr "La hoja %(sheet)s contiene %(count)s partes facturados, que no se pueden borrar nunca."
#. module: hr_timesheet_sheet_delete_cascade
#. odoo-python
#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0
#, python-format
msgid "Sheet %(sheet)s contains entries of time off that cannot be cancelled any more: %(leaves)s. Remove them from the Time Off application first."
msgstr "La hoja %(sheet)s contiene partes de ausencias que ya no se pueden anular: %(leaves)s. Retíralos antes desde la aplicación de Ausencias."
#. module: hr_timesheet_sheet_delete_cascade
#. odoo-python
#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0
#, python-format
msgid "Sheet %(sheet)s is already submitted or approved and cannot be deleted."
msgstr "La hoja %(sheet)s ya está enviada o aprobada y no se puede borrar."
#. module: hr_timesheet_sheet_delete_cascade
#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__sheet_count
msgid "Sheets"
msgstr "Hojas"
#. module: hr_timesheet_sheet_delete_cascade
#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__summary_html
msgid "Summary"
msgstr "Resumen"
#. module: hr_timesheet_sheet_delete_cascade
#. odoo-python
#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0
#, python-format
msgid "The time off requests themselves are not modified and the employee balances do not change."
msgstr "Las ausencias no se modifican y el saldo de los empleados no varía."
#. module: hr_timesheet_sheet_delete_cascade
#: model_terms:ir.ui.view,arch_db:hr_timesheet_sheet_delete_cascade.hr_timesheet_sheet_delete_wizard_form
msgid ""
"This cannot be undone. The selected sheets and the timesheet\n"
" entries they contain will be permanently deleted."
msgstr ""
"Esto no se puede deshacer. Las hojas seleccionadas y los partes de horas\n"
" que contienen se borrarán definitivamente."
#. module: hr_timesheet_sheet_delete_cascade
#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__partial_leave_ids
msgid "Time Off Beyond The Sheet Period"
msgstr "Ausencias que exceden el periodo de la hoja"
#. module: hr_timesheet_sheet_delete_cascade
#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__leave_count
msgid "Time Off Entries"
msgstr "Partes de ausencia"
#. module: hr_timesheet_sheet_delete_cascade
#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__leave_policy
msgid "Time Off Policy"
msgstr "Política de ausencias"
#. module: hr_timesheet_sheet_delete_cascade
#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_res_company__timesheet_sheet_delete_leave_policy
#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_res_config_settings__timesheet_sheet_delete_leave_policy
msgid "Time Off Policy On Sheet Deletion"
msgstr "Política de ausencias al borrar una hoja"
#. module: hr_timesheet_sheet_delete_cascade
#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__leave_ids
msgid "Time Off To Cancel"
msgstr "Ausencias que se van a anular"
#. module: hr_timesheet_sheet_delete_cascade
#. odoo-python
#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0
#, python-format
msgid "Time off"
msgstr "Ausencias"
#. module: hr_timesheet_sheet_delete_cascade
#. odoo-python
#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0
#, python-format
msgid "Time off to cancel"
msgstr "Ausencias que se van a anular"
#. module: hr_timesheet_sheet_delete_cascade
#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__timesheet_count
msgid "Timesheet Entries"
msgstr "Partes de horas"
#. module: hr_timesheet_sheet_delete_cascade
#: model:ir.model,name:hr_timesheet_sheet_delete_cascade.model_hr_timesheet_sheet
msgid "Timesheet Sheet"
msgstr "Hoja de servicio"
#. module: hr_timesheet_sheet_delete_cascade
#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__sheet_ids
msgid "Timesheet Sheets"
msgstr "Hojas de servicio"
#. module: hr_timesheet_sheet_delete_cascade
#. odoo-python
#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0
#, python-format
msgid "Timesheet entries to delete"
msgstr "Partes de horas que se van a borrar"
#. module: hr_timesheet_sheet_delete_cascade
#. odoo-python
#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0
#, python-format
msgid "Timesheet sheets to delete"
msgstr "Hojas de servicio que se van a borrar"
#. module: hr_timesheet_sheet_delete_cascade
#. odoo-python
#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0
#, python-format
msgid "Warning: the following time off requests also cover days outside the period of the deleted sheets, and they will be cancelled in full: %s"
msgstr "Aviso: las siguientes ausencias cubren también días fuera del periodo de las hojas borradas, y se anularán por completo: %s"
#. module: hr_timesheet_sheet_delete_cascade
#: model:ir.model.fields,help:hr_timesheet_sheet_delete_cascade.field_res_company__timesheet_sheet_delete_leave_policy
#: model:ir.model.fields,help:hr_timesheet_sheet_delete_cascade.field_res_config_settings__timesheet_sheet_delete_leave_policy
msgid ""
"What happens to the time off entries contained in a timesheet sheet when the sheet is deleted.\n"
"Keep the time off: the time off request is left untouched, its entries are detached from the sheet and survive, and the employee balance does not change.\n"
"Cancel the time off: the whole time off request is refused and deleted, and the days are restored to the employee balance. This cannot be undone."
msgstr ""
"Qué pasa con los partes de ausencia contenidos en una hoja de servicio cuando se borra la hoja.\n"
"Conservar la ausencia: la ausencia queda intacta, sus partes se desvinculan de la hoja y sobreviven, y el saldo del empleado no varía.\n"
"Anular la ausencia: la ausencia entera se rechaza y se borra, y los días vuelven al saldo del empleado. Esto no se puede deshacer."

View file

@ -0,0 +1,334 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * hr_timesheet_sheet_delete_cascade
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 16.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-09-15 08:20+0000\n"
"PO-Revision-Date: 2026-09-15 08:20+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: hr_timesheet_sheet_delete_cascade
#. odoo-python
#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0
#, python-format
msgid "%(count)s entries, %(hours)s hours in total."
msgstr ""
#. module: hr_timesheet_sheet_delete_cascade
#. odoo-python
#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0
#, python-format
msgid "%(employee)s - %(type)s (%(date_from)s - %(date_to)s): %(days)s days"
msgstr ""
#. module: hr_timesheet_sheet_delete_cascade
#. odoo-python
#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0
#, python-format
msgid "%s days will be restored to the employee balances."
msgstr ""
#. module: hr_timesheet_sheet_delete_cascade
#. odoo-python
#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0
#, python-format
msgid ""
"%s time off entries will be detached from the sheet and will survive the "
"deletion."
msgstr ""
#. module: hr_timesheet_sheet_delete_cascade
#: model_terms:ir.ui.view,arch_db:hr_timesheet_sheet_delete_cascade.res_config_settings_view_form
msgid ""
"<span class=\"fa fa-lg fa-building\" title=\"Values set here are company-"
"specific.\" aria-label=\"Values set here are company-specific.\" "
"groups=\"base.group_multi_company\" role=\"img\"/>"
msgstr ""
#. module: hr_timesheet_sheet_delete_cascade
#: model:ir.model,name:hr_timesheet_sheet_delete_cascade.model_account_analytic_line
msgid "Analytic Line"
msgstr ""
#. module: hr_timesheet_sheet_delete_cascade
#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__blocking_reason
msgid "Blocking Reason"
msgstr ""
#. module: hr_timesheet_sheet_delete_cascade
#: model_terms:ir.ui.view,arch_db:hr_timesheet_sheet_delete_cascade.hr_timesheet_sheet_delete_wizard_form
msgid "Cancel"
msgstr ""
#. module: hr_timesheet_sheet_delete_cascade
#: model:ir.model.fields.selection,name:hr_timesheet_sheet_delete_cascade.selection__hr_timesheet_sheet_delete_wizard__leave_policy__cancel
#: model:ir.model.fields.selection,name:hr_timesheet_sheet_delete_cascade.selection__res_company__timesheet_sheet_delete_leave_policy__cancel
msgid "Cancel the time off and restore the balance"
msgstr ""
#. module: hr_timesheet_sheet_delete_cascade
#: model_terms:ir.ui.view,arch_db:hr_timesheet_sheet_delete_cascade.res_config_settings_view_form
msgid ""
"Choose what happens to the time off entries contained\n"
" in a timesheet sheet when the sheet is deleted. Keeping\n"
" the time off leaves the request untouched and only\n"
" detaches its entries from the sheet. Cancelling the time\n"
" off refuses and deletes the whole request and restores\n"
" the days to the employee balance, including the days\n"
" that fall outside the deleted sheet: it cannot be\n"
" undone."
msgstr ""
#. module: hr_timesheet_sheet_delete_cascade
#: model:ir.model,name:hr_timesheet_sheet_delete_cascade.model_res_company
msgid "Companies"
msgstr ""
#. module: hr_timesheet_sheet_delete_cascade
#: model:ir.model,name:hr_timesheet_sheet_delete_cascade.model_res_config_settings
msgid "Config Settings"
msgstr ""
#. module: hr_timesheet_sheet_delete_cascade
#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__create_uid
msgid "Created by"
msgstr ""
#. module: hr_timesheet_sheet_delete_cascade
#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__create_date
msgid "Created on"
msgstr ""
#. module: hr_timesheet_sheet_delete_cascade
#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__leave_days
msgid "Days Restored"
msgstr ""
#. module: hr_timesheet_sheet_delete_cascade
#: model_terms:ir.ui.view,arch_db:hr_timesheet_sheet_delete_cascade.hr_timesheet_sheet_form
msgid "Delete Sheet"
msgstr ""
#. module: hr_timesheet_sheet_delete_cascade
#. odoo-python
#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0
#, python-format
msgid "Delete Timesheet Sheet"
msgstr ""
#. module: hr_timesheet_sheet_delete_cascade
#: model:ir.actions.act_window,name:hr_timesheet_sheet_delete_cascade.action_hr_timesheet_sheet_delete_wizard
#: model:ir.model,name:hr_timesheet_sheet_delete_cascade.model_hr_timesheet_sheet_delete_wizard
#: model_terms:ir.ui.view,arch_db:hr_timesheet_sheet_delete_cascade.hr_timesheet_sheet_delete_wizard_form
msgid "Delete Timesheet Sheets"
msgstr ""
#. module: hr_timesheet_sheet_delete_cascade
#: model_terms:ir.ui.view,arch_db:hr_timesheet_sheet_delete_cascade.hr_timesheet_sheet_delete_wizard_form
msgid "Delete permanently"
msgstr ""
#. module: hr_timesheet_sheet_delete_cascade
#. odoo-python
#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0
#, python-format
msgid ""
"Deleting a timesheet sheet also deletes its timesheet entries and cannot be "
"undone. Use the Delete Sheet button on the sheet to review what will be "
"deleted and confirm it."
msgstr ""
#. module: hr_timesheet_sheet_delete_cascade
#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__display_name
msgid "Display Name"
msgstr ""
#. module: hr_timesheet_sheet_delete_cascade
#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__timesheet_hours
msgid "Hours"
msgstr ""
#. module: hr_timesheet_sheet_delete_cascade
#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__id
msgid "ID"
msgstr ""
#. module: hr_timesheet_sheet_delete_cascade
#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__invoiced_count
msgid "Invoiced Entries"
msgstr ""
#. module: hr_timesheet_sheet_delete_cascade
#: model:ir.model.fields.selection,name:hr_timesheet_sheet_delete_cascade.selection__hr_timesheet_sheet_delete_wizard__leave_policy__detach
#: model:ir.model.fields.selection,name:hr_timesheet_sheet_delete_cascade.selection__res_company__timesheet_sheet_delete_leave_policy__detach
msgid "Keep the time off, detach its lines"
msgstr ""
#. module: hr_timesheet_sheet_delete_cascade
#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard____last_update
msgid "Last Modified on"
msgstr ""
#. module: hr_timesheet_sheet_delete_cascade
#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__write_uid
msgid "Last Updated by"
msgstr ""
#. module: hr_timesheet_sheet_delete_cascade
#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__write_date
msgid "Last Updated on"
msgstr ""
#. module: hr_timesheet_sheet_delete_cascade
#. odoo-python
#: code:addons/hr_timesheet_sheet_delete_cascade/wizards/delete_sheet_wizard.py:0
#, python-format
msgid "No timesheet sheet was selected."
msgstr ""
#. module: hr_timesheet_sheet_delete_cascade
#. odoo-python
#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0
#, python-format
msgid ""
"Sheet %(sheet)s contains %(count)s invoiced timesheet entries, which can "
"never be deleted."
msgstr ""
#. module: hr_timesheet_sheet_delete_cascade
#. odoo-python
#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0
#, python-format
msgid ""
"Sheet %(sheet)s contains entries of time off that cannot be cancelled any "
"more: %(leaves)s. Remove them from the Time Off application first."
msgstr ""
#. module: hr_timesheet_sheet_delete_cascade
#. odoo-python
#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0
#, python-format
msgid ""
"Sheet %(sheet)s is already submitted or approved and cannot be deleted."
msgstr ""
#. module: hr_timesheet_sheet_delete_cascade
#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__sheet_count
msgid "Sheets"
msgstr ""
#. module: hr_timesheet_sheet_delete_cascade
#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__summary_html
msgid "Summary"
msgstr ""
#. module: hr_timesheet_sheet_delete_cascade
#. odoo-python
#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0
#, python-format
msgid ""
"The time off requests themselves are not modified and the employee balances "
"do not change."
msgstr ""
#. module: hr_timesheet_sheet_delete_cascade
#: model_terms:ir.ui.view,arch_db:hr_timesheet_sheet_delete_cascade.hr_timesheet_sheet_delete_wizard_form
msgid ""
"This cannot be undone. The selected sheets and the timesheet\n"
" entries they contain will be permanently deleted."
msgstr ""
#. module: hr_timesheet_sheet_delete_cascade
#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__partial_leave_ids
msgid "Time Off Beyond The Sheet Period"
msgstr ""
#. module: hr_timesheet_sheet_delete_cascade
#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__leave_count
msgid "Time Off Entries"
msgstr ""
#. module: hr_timesheet_sheet_delete_cascade
#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__leave_policy
msgid "Time Off Policy"
msgstr ""
#. module: hr_timesheet_sheet_delete_cascade
#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_res_company__timesheet_sheet_delete_leave_policy
#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_res_config_settings__timesheet_sheet_delete_leave_policy
msgid "Time Off Policy On Sheet Deletion"
msgstr ""
#. module: hr_timesheet_sheet_delete_cascade
#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__leave_ids
msgid "Time Off To Cancel"
msgstr ""
#. module: hr_timesheet_sheet_delete_cascade
#. odoo-python
#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0
#, python-format
msgid "Time off"
msgstr ""
#. module: hr_timesheet_sheet_delete_cascade
#. odoo-python
#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0
#, python-format
msgid "Time off to cancel"
msgstr ""
#. module: hr_timesheet_sheet_delete_cascade
#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__timesheet_count
msgid "Timesheet Entries"
msgstr ""
#. module: hr_timesheet_sheet_delete_cascade
#: model:ir.model,name:hr_timesheet_sheet_delete_cascade.model_hr_timesheet_sheet
msgid "Timesheet Sheet"
msgstr ""
#. module: hr_timesheet_sheet_delete_cascade
#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__sheet_ids
msgid "Timesheet Sheets"
msgstr ""
#. module: hr_timesheet_sheet_delete_cascade
#. odoo-python
#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0
#, python-format
msgid "Timesheet entries to delete"
msgstr ""
#. module: hr_timesheet_sheet_delete_cascade
#. odoo-python
#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0
#, python-format
msgid "Timesheet sheets to delete"
msgstr ""
#. module: hr_timesheet_sheet_delete_cascade
#. odoo-python
#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0
#, python-format
msgid ""
"Warning: the following time off requests also cover days outside the period "
"of the deleted sheets, and they will be cancelled in full: %s"
msgstr ""
#. module: hr_timesheet_sheet_delete_cascade
#: model:ir.model.fields,help:hr_timesheet_sheet_delete_cascade.field_res_company__timesheet_sheet_delete_leave_policy
#: model:ir.model.fields,help:hr_timesheet_sheet_delete_cascade.field_res_config_settings__timesheet_sheet_delete_leave_policy
msgid ""
"What happens to the time off entries contained in a timesheet sheet when the sheet is deleted.\n"
"Keep the time off: the time off request is left untouched, its entries are detached from the sheet and survive, and the employee balance does not change.\n"
"Cancel the time off: the whole time off request is refused and deleted, and the days are restored to the employee balance. This cannot be undone."
msgstr ""

View file

@ -0,0 +1,4 @@
from . import res_company
from . import res_config_settings
from . import account_analytic_line
from . import hr_timesheet_sheet

View file

@ -0,0 +1,62 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import api, models
# The context of an RPC call is controlled by the client: odoo.api.call_kw
# rebuilds the environment with whatever dictionary arrives in the request, so
# a flag carrying a JSON value could be forged by any authenticated user and
# the cascade would delete a sheet without the confirmation wizard. This
# sentinel is compared by identity and cannot be produced by a deserializer,
# so it only ever reaches the consumer from inside this process.
_SHEET_DELETE_CASCADE = object()
class AccountAnalyticLine(models.Model):
_inherit = "account.analytic.line"
def _sheet_delete_cascade_token(self):
"""Hook for extensions"""
return _SHEET_DELETE_CASCADE
def _in_sheet_delete_cascade(self):
return (
self.env.context.get("sheet_delete_cascade")
is self._sheet_delete_cascade_token()
)
def _sheet_leave_policy(self):
"""Hook for extensions"""
companies = self.sheet_id.company_id or self.company_id or self.env.company
policies = companies.mapped("timesheet_sheet_delete_leave_policy")
return "cancel" if "cancel" in policies else "detach"
def _in_deliberate_delete_cascade(self):
"""Hook for extensions
The sentinel alone is not enough: the elevated privileges that only the
cancellation branch of the sheet grants are required as a second
barrier, so an in-process caller that merely carries the cascade
context cannot delete a time off entry on its way.
"""
return self._in_sheet_delete_cascade() and self.env.su
def _detach_leave_lines_on_unlink(self):
if self._in_deliberate_delete_cascade():
return self._sheet_leave_policy() != "cancel"
return super()._detach_leave_lines_on_unlink()
@api.ondelete(at_uninstall=False)
def _unlink_except_linked_leave(self):
# Defence in depth. The cancellation branch of the cascade never gets
# here with a time off entry: project_timesheet_holidays clears
# holiday_id before unlinking the entries of a refused request
# (models/hr_holidays.py:127-134), so by then they are ordinary lines.
# This keeps the contract true for any other caller that unlinks a time
# off entry from inside a deliberate cascade.
if (
self._in_deliberate_delete_cascade()
and self._sheet_leave_policy() == "cancel"
):
return
return super()._unlink_except_linked_leave()

View file

@ -0,0 +1,312 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import SUPERUSER_ID, _, models
from odoo.exceptions import UserError
from odoo.tools.misc import format_date, format_duration
REFUSABLE_LEAVE_STATES = ("draft", "confirm", "validate", "validate1")
class Sheet(models.Model):
_inherit = "hr_timesheet.sheet"
def _sheet_delete_cascade_token(self):
"""Hook for extensions"""
return self.env["account.analytic.line"]._sheet_delete_cascade_token()
def _with_delete_cascade(self):
"""Hook for extensions
The deliberate way to run the cascade from code. It is a private
method on purpose: a name starting with an underscore cannot be
reached through call_kw, so the sentinel cannot be obtained remotely.
"""
token = self._sheet_delete_cascade_token()
return self.with_context(sheet_delete_cascade=token)
def _in_delete_cascade(self):
return (
self.env.context.get("sheet_delete_cascade")
is self._sheet_delete_cascade_token()
)
def _sheet_leave_policy(self):
"""Hook for extensions
The policy in force for this recordset: ``cancel`` as soon as one
company asks for it, so that a summary never understates what is about
to happen. The destructive path works sheet by sheet, where the
recordset holds a single sheet and the answer is exact.
"""
companies = self.company_id or self.env.company
policies = companies.mapped("timesheet_sheet_delete_leave_policy")
return "cancel" if "cancel" in policies else "detach"
def _delete_cascade_cancel_sheets(self):
"""Hook for extensions"""
return self.filtered(lambda sheet: sheet._sheet_leave_policy() == "cancel")
def _sheet_leave_lines(self):
"""Hook for extensions"""
return self.mapped("timesheet_ids").filtered("holiday_id")
def _sheet_leaves(self):
"""Hook for extensions
Read in sudo: a timesheet manager entitled to delete the sheet of
another employee is not necessarily entitled to read that employee's
time off, and the confirmation wizard has to describe it. Only reached
with the ``cancel`` policy; the ``detach`` policy never reads hr.leave.
"""
return self._sheet_leave_lines().holiday_id.sudo()
def _sheet_leaves_to_cancel(self):
"""Hook for extensions"""
return self._sheet_leaves().filtered(
lambda leave: leave.state in REFUSABLE_LEAVE_STATES
)
def _sheet_leaves_not_cancellable(self):
"""Hook for extensions"""
return self._sheet_leaves().filtered(
lambda leave: leave.state not in REFUSABLE_LEAVE_STATES
)
def _sheet_leaves_beyond_period(self):
"""Hook for extensions"""
sheet_lines = self.mapped("timesheet_ids")
leaves = self._sheet_leaves_to_cancel()
return leaves.filtered(lambda leave: bool(leave.timesheet_ids - sheet_lines))
def _delete_cascade_blocking(self):
"""Hook for extensions"""
reasons = []
for sheet in self:
invoiced = sheet.timesheet_ids.filtered("timesheet_invoice_id")
if invoiced:
reasons.append(
_(
"Sheet %(sheet)s contains %(count)s invoiced timesheet"
" entries, which can never be deleted.",
sheet=sheet.complete_name,
count=len(invoiced),
)
)
if sheet.state in ("confirm", "done"):
reasons.append(
_(
"Sheet %(sheet)s is already submitted or approved and"
" cannot be deleted.",
sheet=sheet.complete_name,
)
)
if sheet._sheet_leave_policy() != "cancel":
continue
stuck = sheet._sheet_leaves_not_cancellable()
if stuck:
reasons.append(
_(
"Sheet %(sheet)s contains entries of time off that"
" cannot be cancelled any more: %(leaves)s. Remove"
" them from the Time Off application first.",
sheet=sheet.complete_name,
leaves=", ".join(
"%s (%s)" % (leave.display_name, leave.state)
for leave in stuck
),
)
)
return reasons
def _delete_cascade_check(self):
"""Hook for extensions"""
reasons = self._delete_cascade_blocking()
if reasons:
raise UserError("\n".join(reasons))
def _delete_cascade_detach_section(self):
"""Hook for extensions"""
leave_lines = self._sheet_leave_lines()
if not leave_lines:
return None
return {
"title": _("Time off"),
"lines": [
_(
"%s time off entries will be detached from the sheet and"
" will survive the deletion.",
len(leave_lines),
),
_(
"The time off requests themselves are not modified and the"
" employee balances do not change."
),
],
}
def _delete_cascade_cancel_section(self):
"""Hook for extensions"""
leaves = self._sheet_leaves_to_cancel()
if not self._sheet_leave_lines():
return None, 0.0
lines = [
_(
"%(employee)s - %(type)s (%(date_from)s - %(date_to)s):"
" %(days)s days",
employee=leave.employee_id.name,
type=leave.holiday_status_id.name,
date_from=format_date(self.env, leave.date_from),
date_to=format_date(self.env, leave.date_to),
days=leave.number_of_days,
)
for leave in leaves
]
leave_days = sum(leaves.mapped("number_of_days"))
lines.append(
_("%s days will be restored to the employee balances.", leave_days)
)
beyond = self._sheet_leaves_beyond_period()
if beyond:
lines.append(
_(
"Warning: the following time off requests also cover days"
" outside the period of the deleted sheets, and they will"
" be cancelled in full: %s",
", ".join(
"%s (%s - %s)"
% (
leave.employee_id.name,
format_date(self.env, leave.date_from),
format_date(self.env, leave.date_to),
)
for leave in beyond
),
)
)
return {"title": _("Time off to cancel"), "lines": lines}, leave_days
def _delete_cascade_leave_sections(self):
"""Hook for extensions
Built per policy rather than per batch: a selection may span companies
that answer differently, and the summary has to describe what each
sheet is really going to do.
"""
sections = []
cancel_sheets = self._delete_cascade_cancel_sheets()
detach_section = (self - cancel_sheets)._delete_cascade_detach_section()
if detach_section:
sections.append(detach_section)
cancel_section, leave_days = cancel_sheets._delete_cascade_cancel_section()
if cancel_section:
sections.append(cancel_section)
return sections, leave_days
def _delete_cascade_prepare(self):
"""Hook for extensions"""
lines = self.mapped("timesheet_ids")
invoiced = lines.filtered("timesheet_invoice_id")
hours = sum(lines.mapped("unit_amount"))
sections = [
{
"title": _("Timesheet sheets to delete"),
"lines": [sheet.complete_name for sheet in self],
},
{
"title": _("Timesheet entries to delete"),
"lines": [
_(
"%(count)s entries, %(hours)s hours in total.",
count=len(lines),
hours=format_duration(hours),
)
],
},
]
leave_sections, leave_days = self._delete_cascade_leave_sections()
sections.extend(leave_sections)
return {
"sheet_count": len(self),
"timesheet_count": len(lines),
"timesheet_hours": hours,
"invoiced_count": len(invoiced),
"leave_count": len(self._sheet_leave_lines()),
"leave_days": leave_days,
"leave_policy": self._sheet_leave_policy(),
"blocking": self._delete_cascade_blocking(),
"sections": sections,
}
def _delete_cascade_cancel_leaves(self):
"""Hook for extensions"""
# sudo() is not enough here, and the extra elevation is deliberate.
# sudo() flips env.su but keeps the uid, and hr.leave's
# _unlink_if_correct_states gates the deletion on
# user_has_groups('hr_holidays.group_hr_holidays_user'), which reads
# the uid: without a Time Off officer behind it, deleting a time off
# that already started raises. SUPERUSER_ID is the only way to run the
# documented refuse/draft/unlink sequence as any employee.
# It stays safe because it is confined to hr.leave, it only ever
# reaches the requests found inside sheets the user is already allowed
# to delete by the record rules of this module, and it is unreachable
# unless the company policy is "cancel".
leaves = self._sheet_leaves_to_cancel().with_user(SUPERUSER_ID).sudo()
if not leaves:
return
leaves.action_refuse()
leaves.action_draft()
leaves.unlink()
def _delete_cascade_apply(self):
"""Hook for extensions"""
for sheet in self:
if sheet._sheet_leave_policy() == "cancel":
sheet._delete_cascade_cancel_leaves()
# action_refuse() calls _check_missing_global_leave_timesheets(),
# which can create public holiday entries that land in this very
# sheet. Re-read the set so they are deleted with it instead of
# being left behind pointing at a sheet that no longer exists.
sheet.invalidate_recordset(["timesheet_ids"])
sheet.timesheet_ids.exists().unlink()
continue
lines = sheet.timesheet_ids.exists()
leave_lines = lines.filtered("holiday_id")
if leave_lines:
leave_lines.write({"sheet_id": False})
other_lines = lines - leave_lines
if other_lines:
other_lines.unlink()
def unlink(self):
if not self._in_delete_cascade():
raise UserError(
_(
"Deleting a timesheet sheet also deletes its timesheet"
" entries and cannot be undone. Use the Delete Sheet"
" button on the sheet to review what will be deleted and"
" confirm it."
)
)
self._delete_cascade_check()
self._delete_cascade_apply()
return super().unlink()
def action_delete_sheet(self):
return {
"type": "ir.actions.act_window",
"name": _("Delete Timesheet Sheet"),
"res_model": "hr_timesheet.sheet.delete.wizard",
"view_mode": "form",
"target": "new",
"context": dict(
self.env.context,
active_model="hr_timesheet.sheet",
active_ids=self.ids,
active_id=self.ids[0] if self.ids else False,
# The button lives in the header of the sheet form: once the
# sheet is gone that form cannot be restored, and the wizard
# has to send the user somewhere else.
sheet_delete_cascade_from_form=True,
),
}

View file

@ -0,0 +1,28 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import fields, models
LEAVE_POLICY_SELECTION = [
("detach", "Keep the time off, detach its lines"),
("cancel", "Cancel the time off and restore the balance"),
]
class ResCompany(models.Model):
_inherit = "res.company"
timesheet_sheet_delete_leave_policy = fields.Selection(
selection=LEAVE_POLICY_SELECTION,
string="Time Off Policy On Sheet Deletion",
default="detach",
required=True,
help="What happens to the time off entries contained in a timesheet"
" sheet when the sheet is deleted.\n"
"Keep the time off: the time off request is left untouched, its"
" entries are detached from the sheet and survive, and the employee"
" balance does not change.\n"
"Cancel the time off: the whole time off request is refused and"
" deleted, and the days are restored to the employee balance. This"
" cannot be undone.",
)

View file

@ -0,0 +1,14 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import fields, models
class ResConfigSettings(models.TransientModel):
_inherit = "res.config.settings"
timesheet_sheet_delete_leave_policy = fields.Selection(
related="company_id.timesheet_sheet_delete_leave_policy",
readonly=False,
required=True,
)

View file

@ -0,0 +1,24 @@
Two things are not resolved by installing the module and have to be reviewed
by hand.
**The record rules change existing permissions.** Before this module, every
internal user could write and delete the timesheet sheet of any other
employee. The module restricts writing and deleting to the owner of the sheet
for every internal user (``base.group_user``), and lifts the restriction for
the members of *Timesheets / Administrator*
(``hr_timesheet.group_timesheet_manager``), who keep access to every sheet.
The restrictive rule is set on ``base.group_user`` on purpose: a rule set on a
timesheet group would leave any internal user outside that group with the
unrestricted access the ACL grants and no rule at all. Reading is left open so
that the *All Timesheet Sheets* menu and the reports keep working. Check with
the customer whether any flow relies on a colleague editing somebody else's
sheet before installing.
**The time off policy has a conservative default.** In
*Settings / Timesheets / Timesheet Options*, the field *Time Off Policy On
Sheet Deletion* is per company and defaults to *Keep the time off, detach its
lines*: deleting a sheet never touches a time off request nor an employee
balance. An installation that expects the deletion of a sheet to cancel the
time off it contains has to set *Cancel the time off and restore the balance*
on purpose. That value is irreversible and cancels the whole request, even the
days that fall in other weeks.

View file

@ -0,0 +1,3 @@
* Criptomart
* Luis Martínez <luis@criptomart.net>

View file

@ -0,0 +1,19 @@
In ``hr_timesheet_sheet``, deleting a timesheet sheet does not delete the
timesheet entries it contains: they are left behind with an empty ``Sheet``
field, invisible from any sheet and hard to find again.
This module makes the deletion of a sheet cascade to its timesheet entries,
behind a confirmation wizard that states beforehand how many entries and how
many hours are about to disappear. Invoiced entries are never deleted: a sheet
that contains one cannot be deleted at all.
Because a sheet also contains the entries generated by approved time off, the
module makes that case an explicit company policy: either the time off request
is kept untouched and only its entries are detached from the sheet (the
default), or the request is cancelled and the days are restored to the
employee balance.
Turning a harmless ``unlink()`` into a destructive one is only safe if the
record rules are in place first, so the module also adds the record rules that
``hr_timesheet.sheet`` is missing: without them any internal user can delete
the sheet of any other employee.

View file

@ -0,0 +1,16 @@
* Public holidays generate timesheet entries carrying ``global_leave_id``
instead of ``holiday_id``. Neither policy applies to them: they do not belong
to a single employee and there is no balance to restore. They are deleted
with the sheet like any other entry, and they are removed on their own when
the public holiday itself is deleted, through the ``ondelete='cascade'`` of
the field.
* The ``cancel`` policy refuses, resets and deletes the time off request. An
installation that wants to keep a history of the cancelled requests would
stop at the refusal, which is already where the days are restored to the
balance. It is a known variant and it is not implemented.
* The confirmation wizard reads the time off requests in ``sudo`` under the
``cancel`` policy, because a timesheet manager entitled to delete the sheet
of another employee is not necessarily entitled to read that employee's time
off.

View file

@ -0,0 +1,33 @@
The native trash bin is removed from the sheet form, list and kanban views.
Sheets are deleted from the **Delete Sheet** button in the header of the sheet
form, or from the **Action** menu of the list view for several sheets at once.
Both open a confirmation wizard that lists the sheets, the number of timesheet
entries and the total number of hours that will be deleted, and a time off
section that depends on the configured policy. Nothing is deleted until
**Delete permanently** is pressed, and the button is hidden when something
blocks the deletion.
Deleting from the form leaves the user on the list of timesheet sheets, since
the form he was on shows a sheet that no longer exists: *My Timesheet Sheets*,
or *All Timesheet Sheets* when a timesheet administrator deletes the sheet of
another employee. The deleted sheet is not kept in the breadcrumb. Deleting
several sheets from the **Action** menu of a list leaves that list where it
was, with its filters and its grouping.
A plain ``sheet.unlink()`` from a script or a server action raises a
``UserError`` that points to the button, so the confirmation step cannot be
skipped by accident. Code that really means to delete does
``sheet._with_delete_cascade().unlink()``.
The cascade is armed by a sentinel object rather than by a plain value in the
context, and ``_with_delete_cascade()`` is the only way to obtain it. The
context of an RPC call is controlled by the caller, so a context flag holding
a JSON value could be forged by any authenticated user and would delete a
sheet without ever showing the confirmation; a sentinel compared by identity
cannot be produced by a deserializer, and a method whose name starts with an
underscore cannot be reached remotely.
With the ``Cancel the time off`` policy, cancelling a time off request posts a
message in its chatter telling the employee that the request has been refused.
The wording talks about a refusal, not about a deleted timesheet sheet.

View file

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8" ?>
<!--
Copyright 2026 Criptomart
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
-->
<odoo noupdate="1">
<record id="timesheet_sheet_rule_own" model="ir.rule">
<field name="name">Timesheet sheet: only own sheets can be changed</field>
<field name="model_id" ref="hr_timesheet_sheet.model_hr_timesheet_sheet" />
<field name="domain_force">[('employee_id.user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('base.group_user'))]" />
<field name="perm_read" eval="False" />
<field name="perm_write" eval="True" />
<field name="perm_create" eval="False" />
<field name="perm_unlink" eval="True" />
</record>
<record id="timesheet_sheet_rule_manager" model="ir.rule">
<field name="name">Timesheet sheet: managers can change every sheet</field>
<field name="model_id" ref="hr_timesheet_sheet.model_hr_timesheet_sheet" />
<field name="domain_force">[(1, '=', 1)]</field>
<field
name="groups"
eval="[(4, ref('hr_timesheet.group_timesheet_manager'))]"
/>
<field name="perm_read" eval="True" />
<field name="perm_write" eval="True" />
<field name="perm_create" eval="True" />
<field name="perm_unlink" eval="True" />
</record>
</odoo>

View file

@ -0,0 +1,2 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_hr_timesheet_sheet_delete_wizard,hr_timesheet.sheet.delete.wizard,model_hr_timesheet_sheet_delete_wizard,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_hr_timesheet_sheet_delete_wizard hr_timesheet.sheet.delete.wizard model_hr_timesheet_sheet_delete_wizard base.group_user 1 1 1 1

View file

@ -0,0 +1 @@
from . import test_hr_timesheet_sheet_delete_cascade

View file

@ -0,0 +1,811 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
import json
from datetime import date, datetime
from odoo import SUPERUSER_ID
from odoo.api import call_kw
from odoo.exceptions import AccessError, UserError
from odoo.tests import tagged
from odoo.tests.common import TransactionCase
WEEK_START = date(2026, 10, 5)
WEEK_END = date(2026, 10, 11)
@tagged("post_install", "-at_install")
class TestHrTimesheetSheetDeleteCascade(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.env = cls.env(
context=dict(
cls.env.context,
tracking_disable=True,
no_reset_password=True,
mail_create_nolog=True,
mail_notrack=True,
)
)
cls.company = cls.env["res.company"].create({"name": "Delete Cascade Co"})
cls.env.user.write({"company_ids": [(4, cls.company.id)]})
cls.company.resource_calendar_id.tz = "UTC"
cls.internal_project = cls.company.internal_project_id
cls.timeoff_task = cls.company.leave_timesheet_task_id
cls.user = cls._new_user("tsdc_employee")
cls.user_other = cls._new_user("tsdc_other")
cls.user_span = cls._new_user("tsdc_span")
cls.user_manager = cls._new_user(
"tsdc_manager", group="hr_timesheet.group_timesheet_manager"
)
cls.user_internal = cls._new_internal_user("tsdc_internal")
cls.employee = cls._new_employee("Cascade Employee", cls.user)
cls.employee_other = cls._new_employee("Other Employee", cls.user_other)
cls.employee_span = cls._new_employee("Span Employee", cls.user_span)
cls.employee_manager = cls._new_employee("Cascade Manager", cls.user_manager)
cls.project = cls.env["project.project"].create(
{
"name": "Delete Cascade Project",
"company_id": cls.company.id,
"allow_timesheets": True,
"is_timesheet_task_required": False,
}
)
cls.leave_type = cls.env["hr.leave.type"].create(
{
"name": "Paid Time Off (cascade test)",
"company_id": cls.company.id,
"requires_allocation": "yes",
"allocation_validation_type": "no",
"leave_validation_type": "no_validation",
"timesheet_generate": True,
"timesheet_project_id": cls.internal_project.id,
"timesheet_task_id": cls.timeoff_task.id,
}
)
cls._allocate(cls.employee)
cls._allocate(cls.employee_span)
cls.leave = cls._request_leave(
cls.employee,
datetime(2026, 10, 6, 6, 0, 0),
datetime(2026, 10, 8, 18, 0, 0),
)
cls.leave_lines = cls.leave.sudo().timesheet_ids
cls.leave_span = cls._request_leave(
cls.employee_span,
datetime(2026, 10, 8, 6, 0, 0),
datetime(2026, 10, 13, 18, 0, 0),
)
cls.leave_span_lines = cls.leave_span.sudo().timesheet_ids
cls.sheet = cls._new_sheet(cls.employee)
cls.regular_line = cls._new_regular_line(cls.employee, 4.0)
cls.sheet._compute_timesheet_ids()
cls.sheet_other = cls._new_sheet(cls.employee_other)
# Fixture helpers
@classmethod
def _new_user(cls, login, group="hr_timesheet.group_hr_timesheet_user"):
return (
cls.env["res.users"]
.with_context(no_reset_password=True, default_role_line_ids=[])
.create(
{
"name": login,
"login": login,
"email": "%s@example.org" % login,
"company_id": cls.company.id,
"company_ids": [(6, 0, [cls.company.id])],
"groups_id": [
(
6,
0,
[
cls.env.ref(group).id,
cls.env.ref("project.group_project_user").id,
# Third party add-ons read project_id.stage_id
# when a timesheet line is written, and that
# field is protected by this group.
cls.env.ref("project.group_project_stages").id,
],
)
],
}
)
)
@classmethod
def _new_internal_user(cls, login):
return (
cls.env["res.users"]
.with_context(no_reset_password=True, default_role_line_ids=[])
.create(
{
"name": login,
"login": login,
"email": "%s@example.org" % login,
"company_id": cls.company.id,
"company_ids": [(6, 0, [cls.company.id])],
"groups_id": [(6, 0, [cls.env.ref("base.group_user").id])],
}
)
)
@classmethod
def _new_employee(cls, name, user):
return cls.env["hr.employee"].create(
{"name": name, "user_id": user.id, "company_id": cls.company.id}
)
@classmethod
def _allocate(cls, employee):
allocation = cls.env["hr.leave.allocation"].create(
{
"name": "Allocation for %s" % employee.name,
"holiday_status_id": cls.leave_type.id,
"employee_id": employee.id,
"number_of_days": 25,
"date_from": date(2026, 1, 1),
}
)
if allocation.state != "validate":
allocation.action_validate()
return allocation
@classmethod
def _request_leave(cls, employee, date_from, date_to):
return cls.env["hr.leave"].create(
{
"name": "Time off for %s" % employee.name,
"holiday_status_id": cls.leave_type.id,
"employee_id": employee.id,
"date_from": date_from,
"date_to": date_to,
}
)
@classmethod
def _new_sheet(cls, employee):
return cls.env["hr_timesheet.sheet"].create(
{
"employee_id": employee.id,
"company_id": cls.company.id,
"date_start": WEEK_START,
"date_end": WEEK_END,
}
)
@classmethod
def _new_regular_line(cls, employee, hours):
return cls.env["account.analytic.line"].create(
{
"name": "Regular work",
"project_id": cls.project.id,
"employee_id": employee.id,
"date": WEEK_START,
"unit_amount": hours,
}
)
# Test helpers
def _set_policy(self, policy):
self.company.timesheet_sheet_delete_leave_policy = policy
def _recompute(self, records):
token = self.env["account.analytic.line"]._sheet_recompute_token()
return records.with_context(sheet_recompute=token)
def _cascade(self, records):
token = self.env["account.analytic.line"]._sheet_delete_cascade_token()
return records.with_context(sheet_delete_cascade=token)
def _wizard(self, sheets, user=None):
return (
self.env["hr_timesheet.sheet.delete.wizard"]
.with_user(user or self.user)
.with_context(
active_model="hr_timesheet.sheet", active_ids=sheets.ids
)
.create({})
)
def _wizard_from_form(self, sheets, user=None):
user = user or self.user
action = sheets.with_user(user).action_delete_sheet()
return (
self.env["hr_timesheet.sheet.delete.wizard"]
.with_user(user)
.with_context(**action["context"])
.create({})
)
def _leave_balance(self, employee):
leave_type = self.leave_type.with_context(employee_id=employee.id)
leave_type.invalidate_recordset()
return leave_type.leaves_taken, leave_type.virtual_remaining_leaves
def _span_sheet(self):
sheet = self._new_sheet(self.employee_span)
sheet._compute_timesheet_ids()
return sheet
def _second_company_sheet_with_leave(self, policy):
company = self.env["res.company"].create({"name": "Cascade Co %s" % policy})
company.resource_calendar_id.tz = "UTC"
company.timesheet_sheet_delete_leave_policy = policy
user = (
self.env["res.users"]
.with_context(no_reset_password=True, default_role_line_ids=[])
.create(
{
"name": "tsdc_%s" % policy,
"login": "tsdc_%s" % policy,
"email": "tsdc_%s@example.org" % policy,
"company_id": company.id,
"company_ids": [(6, 0, [company.id])],
"groups_id": [
(
6,
0,
[
self.env.ref(
"hr_timesheet.group_hr_timesheet_user"
).id,
self.env.ref("project.group_project_user").id,
self.env.ref("project.group_project_stages").id,
],
)
],
}
)
)
employee = self.env["hr.employee"].create(
{
"name": "Employee %s" % policy,
"user_id": user.id,
"company_id": company.id,
}
)
leave_type = self.env["hr.leave.type"].create(
{
"name": "Paid Time Off (%s)" % policy,
"company_id": company.id,
"requires_allocation": "yes",
"allocation_validation_type": "no",
"leave_validation_type": "no_validation",
"timesheet_generate": True,
"timesheet_project_id": company.internal_project_id.id,
"timesheet_task_id": company.leave_timesheet_task_id.id,
}
)
allocation = self.env["hr.leave.allocation"].create(
{
"name": "Allocation %s" % policy,
"holiday_status_id": leave_type.id,
"employee_id": employee.id,
"number_of_days": 25,
"date_from": date(2026, 1, 1),
}
)
if allocation.state != "validate":
allocation.action_validate()
leave = self.env["hr.leave"].create(
{
"name": "Time off %s" % policy,
"holiday_status_id": leave_type.id,
"employee_id": employee.id,
"date_from": datetime(2026, 10, 6, 6, 0, 0),
"date_to": datetime(2026, 10, 7, 18, 0, 0),
}
)
sheet = self.env["hr_timesheet.sheet"].create(
{
"employee_id": employee.id,
"company_id": company.id,
"date_start": WEEK_START,
"date_end": WEEK_END,
}
)
sheet._compute_timesheet_ids()
return {
"company": company,
"user": user,
"employee": employee,
"leave": leave,
"sheet": sheet,
}
def _invoice(self):
journal = self.env["account.journal"].create(
{
"name": "Cascade Test Journal",
"code": "CTJ",
"type": "general",
"company_id": self.company.id,
}
)
return self.env["account.move"].create(
{
"move_type": "entry",
"journal_id": journal.id,
"company_id": self.company.id,
}
)
# Fixture sanity
def test_fixture(self):
self.assertEqual(len(self.leave_lines), 3)
self.assertEqual(self.leave.state, "validate")
self.assertEqual(len(self.leave_span_lines), 4)
self.assertEqual(
self.sheet.timesheet_ids, self.leave_lines | self.regular_line
)
# RF-01
def test_wizard_preview_does_not_delete(self):
wizard = self._wizard(self.sheet)
self.assertEqual(wizard.sheet_count, 1)
self.assertEqual(wizard.timesheet_count, len(self.sheet.timesheet_ids))
self.assertEqual(
wizard.timesheet_hours,
sum(self.sheet.timesheet_ids.mapped("unit_amount")),
)
self.assertIn(self.sheet.complete_name, wizard.summary_html)
self.assertTrue(self.sheet.exists())
self.assertEqual(self.sheet.timesheet_ids.exists(), self.sheet.timesheet_ids)
def test_wizard_action_opens_on_selected_sheets(self):
action = self.sheet.with_user(self.user).action_delete_sheet()
self.assertEqual(action["res_model"], "hr_timesheet.sheet.delete.wizard")
self.assertEqual(action["context"]["active_ids"], self.sheet.ids)
# Navigation once the sheet the user was looking at is gone
def test_delete_from_form_returns_to_the_sheet_list(self):
wizard = self._wizard_from_form(self.sheet)
action = wizard.action_confirm_delete()
self.assertEqual(action["type"], "ir.actions.act_window")
self.assertEqual(action["res_model"], "hr_timesheet.sheet")
self.assertEqual(action["target"], "main")
self.assertIn("tree", action["view_mode"])
self.assertFalse(self.sheet.exists())
def test_delete_from_form_returns_own_sheets_to_an_employee(self):
action = self._wizard_from_form(self.sheet).action_confirm_delete()
self.assertEqual(
action["xml_id"],
"hr_timesheet_sheet.act_hr_timesheet_sheet_my_timesheets",
)
def test_delete_from_form_returns_own_sheets_to_a_manager(self):
manager_sheet = self._new_sheet(self.employee_manager)
action = self._wizard_from_form(
manager_sheet, user=self.user_manager
).action_confirm_delete()
self.assertEqual(
action["xml_id"],
"hr_timesheet_sheet.act_hr_timesheet_sheet_my_timesheets",
)
def test_delete_of_another_employee_sheet_returns_all_sheets(self):
action = self._wizard_from_form(
self.sheet_other, user=self.user_manager
).action_confirm_delete()
self.assertEqual(
action["xml_id"],
"hr_timesheet_sheet.act_hr_timesheet_sheet_all_timesheets",
)
self.assertFalse(self.sheet_other.exists())
def test_delete_from_list_stays_on_the_list(self):
action = self._wizard(self.sheet).action_confirm_delete()
self.assertEqual(action, {"type": "ir.actions.act_window_close"})
self.assertFalse(self.sheet.exists())
def test_wizard_is_not_bound_to_the_sheet_form(self):
action = self.env.ref(
"hr_timesheet_sheet_delete_cascade"
".action_hr_timesheet_sheet_delete_wizard"
)
self.assertNotIn("form", action.binding_view_types)
def test_blocked_deletion_navigates_nowhere(self):
self.regular_line.timesheet_invoice_id = self._invoice()
wizard = self._wizard_from_form(self.sheet)
with self.assertRaises(UserError):
wizard.action_confirm_delete()
self.assertTrue(self.sheet.exists())
# RF-02
def test_cascade_deletes_timesheets(self):
self._set_policy("detach")
sheet = self.sheet_other
line_a = self._new_regular_line(self.employee_other, 2.0)
line_b = self._new_regular_line(self.employee_other, 3.0)
sheet._compute_timesheet_ids()
line_ids = sheet.timesheet_ids.ids
self.assertEqual(sorted(line_ids), sorted((line_a | line_b).ids))
self._wizard(sheet, user=self.user_other).action_confirm_delete()
self.assertFalse(sheet.exists())
self.assertFalse(self.env["account.analytic.line"].browse(line_ids).exists())
def test_bare_unlink_is_refused(self):
with self.assertRaises(UserError):
self.sheet.with_user(self.user).unlink()
self.assertTrue(self.sheet.exists())
# RF-03
def test_user_cannot_delete_other_sheet(self):
other = self.sheet_other.with_user(self.user)
with self.assertRaises(AccessError):
other.check_access_rule("unlink")
with self.assertRaises(AccessError):
other.check_access_rule("write")
def test_user_can_delete_own_sheet(self):
own = self.sheet.with_user(self.user)
own.check_access_rule("unlink")
own.check_access_rule("write")
def test_internal_user_without_timesheet_group_cannot_delete_other_sheet(self):
self.assertFalse(
self.user_internal.has_group("hr_timesheet.group_hr_timesheet_user")
)
sheet = self.sheet.with_user(self.user_internal)
with self.assertRaises(AccessError):
sheet.check_access_rule("unlink")
with self.assertRaises(AccessError):
sheet.check_access_rule("write")
def test_manager_can_delete_any_sheet(self):
sheets = (self.sheet | self.sheet_other).with_user(self.user_manager)
sheets.check_access_rule("unlink")
sheets.check_access_rule("write")
# RF-04
def test_invoiced_timesheets_block_deletion(self):
self.regular_line.timesheet_invoice_id = self._invoice()
wizard = self._wizard(self.sheet)
self.assertEqual(wizard.invoiced_count, 1)
self.assertTrue(wizard.blocking_reason)
self.assertIn(self.sheet.complete_name, wizard.blocking_reason)
self.assertIn("1", wizard.blocking_reason)
with self.assertRaises(UserError):
wizard.action_confirm_delete()
with self.assertRaises(UserError):
self._cascade(self.sheet.with_user(self.user)).unlink()
self.assertTrue(self.sheet.exists())
# RF-05
def test_policy_default_is_detach(self):
company = self.env["res.company"].create({"name": "Fresh Cascade Co"})
self.assertEqual(company.timesheet_sheet_delete_leave_policy, "detach")
self.assertEqual(self.sheet._sheet_leave_policy(), "detach")
def test_policy_detach_keeps_leave(self):
self._set_policy("detach")
leave_line_ids = self.leave_lines.ids
self._wizard(self.sheet).action_confirm_delete()
self.assertFalse(self.sheet.exists())
self.assertFalse(self.regular_line.exists())
lines = self.env["account.analytic.line"].browse(leave_line_ids)
self.assertEqual(lines.exists(), lines)
self.assertFalse(lines.mapped("sheet_id"))
self.assertTrue(self.leave.exists())
self.assertEqual(self.leave.state, "validate")
def test_policy_detach_keeps_balance(self):
self._set_policy("detach")
taken, remaining = self._leave_balance(self.employee)
self._wizard(self.sheet).action_confirm_delete()
self.assertEqual(self._leave_balance(self.employee), (taken, remaining))
# RF-06
def test_cascade_cancels_leaves_and_restores_balance(self):
self._set_policy("cancel")
taken, remaining = self._leave_balance(self.employee)
self.assertEqual(taken, 3.0)
leave_line_ids = self.leave_lines.ids
leave = self.leave
self._wizard(self.sheet).action_confirm_delete()
self.assertFalse(self.sheet.exists())
self.assertFalse(
self.env["account.analytic.line"].browse(leave_line_ids).exists()
)
self.assertFalse(self.regular_line.exists())
self.assertFalse(leave.exists())
new_taken, new_remaining = self._leave_balance(self.employee)
self.assertEqual(new_taken, taken - 3.0)
self.assertEqual(new_remaining, remaining + 3.0)
# RF-07
def test_wizard_lists_leaves(self):
self._set_policy("cancel")
wizard = self._wizard(self.sheet)
self.assertEqual(wizard.leave_policy, "cancel")
self.assertEqual(wizard.leave_count, len(self.leave_lines))
self.assertEqual(wizard.leave_days, 3.0)
self.assertEqual(wizard.leave_ids, self.leave)
self.assertFalse(wizard.partial_leave_ids)
self.assertIn(self.employee.name, wizard.summary_html)
self.assertIn(self.leave_type.name, wizard.summary_html)
def test_wizard_warns_about_leaves_spanning_sheets(self):
self._set_policy("cancel")
sheet = self._span_sheet()
wizard = self._wizard(sheet, user=self.user_span)
self.assertEqual(wizard.partial_leave_ids, self.leave_span)
self.assertIn("Warning", wizard.summary_html)
self.assertIn(self.employee_span.name, wizard.summary_html)
def test_wizard_detach_policy_states_leaves_untouched(self):
self._set_policy("detach")
wizard = self._wizard(self.sheet)
self.assertEqual(wizard.leave_policy, "detach")
self.assertEqual(wizard.leave_count, len(self.leave_lines))
self.assertEqual(wizard.leave_days, 0.0)
self.assertFalse(wizard.leave_ids)
self.assertFalse(wizard.partial_leave_ids)
self.assertIn("balances do not change", wizard.summary_html)
def test_wizard_without_leaves_has_no_leave_section(self):
line = self._new_regular_line(self.employee_other, 2.0)
sheet = self.sheet_other
sheet._compute_timesheet_ids()
self.assertEqual(sheet.timesheet_ids, line)
for policy in ("detach", "cancel"):
self._set_policy(policy)
wizard = self._wizard(sheet, user=self.user_other)
self.assertEqual(wizard.leave_count, 0)
self.assertNotIn("Time off", wizard.summary_html)
def test_uncancellable_leave_blocks_deletion(self):
self._set_policy("cancel")
# A request already refused outside this module keeps its entries:
# action_refuse() would have removed them, a data fix would not.
self.leave.with_user(SUPERUSER_ID).sudo().write({"state": "refuse"})
self.assertEqual(self.sheet._sheet_leaves_to_cancel(), self.env["hr.leave"])
self.assertEqual(self.sheet._sheet_leaves_not_cancellable(), self.leave)
wizard = self._wizard(self.sheet)
self.assertTrue(wizard.blocking_reason)
self.assertIn(self.sheet.complete_name, wizard.blocking_reason)
self.assertIn("cannot be cancelled", wizard.blocking_reason)
with self.assertRaises(UserError):
wizard.action_confirm_delete()
self.env.invalidate_all()
self.assertTrue(self.sheet.exists())
self.assertEqual(self.sheet.timesheet_ids.exists(), self.sheet.timesheet_ids)
def test_multicompany_batch_summary_matches_each_policy(self):
self._set_policy("cancel")
other = self._second_company_sheet_with_leave("detach")
self.env.user.write({"company_ids": [(4, other["company"].id)]})
sheets = self.sheet | other["sheet"]
data = sheets._delete_cascade_prepare()
titles = [section["title"] for section in data["sections"]]
# Compared against the titles the model itself produces, so the test
# does not depend on the language of the user running it.
self.assertIn(other["sheet"]._delete_cascade_detach_section()["title"], titles)
self.assertIn(self.sheet._delete_cascade_cancel_section()[0]["title"], titles)
self.assertEqual(data["leave_days"], 3.0)
self.assertEqual(data["leave_count"], 5)
cancel_title = self.sheet._delete_cascade_cancel_section()[0]["title"]
cancelled = " ".join(
line
for section in data["sections"]
if section["title"] == cancel_title
for line in section["lines"]
)
self.assertIn(self.employee.name, cancelled)
self.assertNotIn(other["employee"].name, cancelled)
wizard = self._wizard(sheets, user=self.env.user)
self.assertEqual(wizard.leave_ids, self.leave)
self.assertEqual(wizard.leave_days, 3.0)
def test_public_holiday_lines_created_while_cancelling_are_deleted(self):
self._set_policy("cancel")
global_leave = (
self.env["resource.calendar.leaves"]
.with_context(allowed_company_ids=[self.company.id])
.create(
{
"name": "Public holiday",
"calendar_id": self.company.resource_calendar_id.id,
"company_id": self.company.id,
"date_from": datetime(2026, 10, 7, 0, 0, 0),
"date_to": datetime(2026, 10, 7, 23, 59, 0),
}
)
)
self.assertFalse(global_leave.timesheet_ids)
sheet_id = self.sheet.id
self._wizard(self.sheet).action_confirm_delete()
self.env.flush_all()
self.env.invalidate_all()
self.assertFalse(self.sheet.exists())
self.assertFalse(global_leave.timesheet_ids)
self.env.cr.execute(
"SELECT COUNT(*) FROM account_analytic_line WHERE sheet_id = %s",
(sheet_id,),
)
self.assertEqual(self.env.cr.fetchone()[0], 0)
# RF-08
def test_bare_unlink_of_leave_line_is_refused(self):
line = self.leave_lines[0].with_user(self.user)
with self.assertRaises(UserError):
line.unlink()
self.env.invalidate_all()
self.assertTrue(self.leave_lines[0].exists())
self.assertTrue(self.leave_lines[0].sheet_id)
def test_unlink_during_sheet_recompute_detaches(self):
line = self.leave_lines[0].with_user(self.user)
self._recompute(line).unlink()
self.env.invalidate_all()
self.assertTrue(self.leave_lines[0].exists())
self.assertFalse(self.leave_lines[0].sheet_id)
self.assertTrue(self.leave.exists())
def test_unlink_during_delete_cascade_cancel_deletes(self):
self._set_policy("cancel")
line = self.leave_lines[0]
self._cascade(line.with_user(self.user).sudo()).unlink()
self.assertFalse(line.exists())
def test_unlink_during_delete_cascade_detach_detaches(self):
self._set_policy("detach")
line = self.leave_lines[0]
self._cascade(line.with_user(self.user).sudo()).unlink()
self.env.invalidate_all()
self.assertTrue(line.exists())
self.assertFalse(line.sheet_id)
self.assertTrue(self.leave.exists())
def test_delete_cascade_flag_without_sudo_is_refused(self):
line = self.leave_lines[0].with_user(self.user)
for policy in ("detach", "cancel"):
self._set_policy(policy)
with self.assertRaises(UserError):
self._cascade(line).unlink()
self.env.invalidate_all()
self.assertTrue(self.leave_lines[0].exists())
self.assertTrue(self.leave_lines[0].sheet_id)
def test_both_flags_delete_cascade_wins(self):
self._set_policy("cancel")
line = self.leave_lines[0]
self._cascade(self._recompute(line.with_user(self.user).sudo())).unlink()
self.assertFalse(line.exists())
def test_both_flags_with_detach_policy_still_detaches(self):
self._set_policy("detach")
line = self.leave_lines[0]
self._cascade(self._recompute(line.with_user(self.user).sudo())).unlink()
self.env.invalidate_all()
self.assertTrue(line.exists())
self.assertFalse(line.sheet_id)
# RF-09
def test_no_cascade_flag_leak_after_deletion(self):
self._set_policy("cancel")
self._wizard(self.sheet).action_confirm_delete()
self.assertIsNone(self.env.context.get("sheet_delete_cascade"))
other_line = self.leave_span_lines[0].with_user(self.user_span)
with self.assertRaises(UserError):
other_line.unlink()
self.env.invalidate_all()
self.assertTrue(self.leave_span_lines[0].exists())
self.assertIsNone(self.env.context.get("sheet_delete_cascade"))
# Published hooks
def test_delete_cascade_prepare_contract(self):
data = self.sheet._delete_cascade_prepare()
self.assertEqual(
set(data),
{
"sheet_count",
"timesheet_count",
"timesheet_hours",
"invoiced_count",
"leave_count",
"leave_days",
"leave_policy",
"blocking",
"sections",
},
)
self.assertEqual(data["blocking"], [])
self.assertTrue(all("title" in s and "lines" in s for s in data["sections"]))
def test_detach_hook_matrix(self):
"""One assertion per row of the RF-08 table, on the same leave line.
The base recordset is bound to a plain employee on purpose: the class
env is superuser, so a line taken straight from the fixture would carry
env.su and the row that must fail without it would pass for the wrong
reason.
"""
line = self.leave_lines[0].with_user(self.user)
self.assertFalse(line.env.su)
line_su = line.sudo()
# Row 1: no flags.
self.assertFalse(line._detach_leave_lines_on_unlink())
# Row 2: sheet recompute, any policy.
self.assertTrue(self._recompute(line)._detach_leave_lines_on_unlink())
self._set_policy("cancel")
# Row 3: deliberate cascade with cancel deletes instead of detaching.
self.assertFalse(self._cascade(line_su)._detach_leave_lines_on_unlink())
# Row 5, cancel: the sentinel alone, without env.su, detaches nothing.
self.assertFalse(self._cascade(line)._detach_leave_lines_on_unlink())
self.assertFalse(self._cascade(line)._in_deliberate_delete_cascade())
# Row 6: the cascade wins over a concurrent recompute.
self.assertFalse(
self._cascade(self._recompute(line_su))._detach_leave_lines_on_unlink()
)
self._set_policy("detach")
# Row 4: deliberate cascade with detach keeps the line alive.
self.assertTrue(self._cascade(line_su)._detach_leave_lines_on_unlink())
# Row 5, detach: still nothing without env.su.
self.assertFalse(self._cascade(line)._detach_leave_lines_on_unlink())
self.assertFalse(self._cascade(line)._in_deliberate_delete_cascade())
def test_context_token_is_not_serializable(self):
token = self.env["account.analytic.line"]._sheet_delete_cascade_token()
with self.assertRaises(TypeError):
json.dumps(token)
def test_forged_cascade_flag_does_not_delete_sheet(self):
model = self.env["hr_timesheet.sheet"].with_user(self.user)
with self.assertRaises(UserError):
call_kw(
model,
"unlink",
[self.sheet.ids],
{"context": {"sheet_delete_cascade": True}},
)
self.env.invalidate_all()
self.assertTrue(self.sheet.exists())
self.assertEqual(self.sheet.timesheet_ids.exists(), self.sheet.timesheet_ids)
self.assertFalse(
model.with_context(sheet_delete_cascade=True)._in_delete_cascade()
)
def test_forged_cascade_flag_does_not_delete_leave_line(self):
model = self.env["account.analytic.line"].with_user(self.user)
line = self.leave_lines[0]
self._set_policy("cancel")
with self.assertRaises(UserError):
call_kw(
model,
"unlink",
[[line.id]],
{"context": {"sheet_delete_cascade": True}},
)
self.env.invalidate_all()
self.assertTrue(line.exists())
self.assertFalse(
model.sudo()
.with_context(sheet_delete_cascade=True)
._in_deliberate_delete_cascade()
)

View file

@ -0,0 +1,62 @@
<?xml version="1.0" encoding="utf-8" ?>
<!--
Copyright 2026 Criptomart
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
-->
<odoo>
<record id="hr_timesheet_sheet_form" model="ir.ui.view">
<field name="name">hr_timesheet.sheet.form.delete.cascade</field>
<field name="model">hr_timesheet.sheet</field>
<field name="inherit_id" ref="hr_timesheet_sheet.hr_timesheet_sheet_form" />
<field name="arch" type="xml">
<form position="attributes">
<attribute name="delete">0</attribute>
</form>
<xpath expr="//header" position="inside">
<button
name="action_delete_sheet"
string="Delete Sheet"
type="object"
class="btn-danger"
attrs="{'invisible': [('state', 'not in', ['new', 'draft'])]}"
/>
</xpath>
</field>
</record>
<record id="hr_timesheet_sheet_tree" model="ir.ui.view">
<field name="name">hr_timesheet.sheet.tree.delete.cascade</field>
<field name="model">hr_timesheet.sheet</field>
<field name="inherit_id" ref="hr_timesheet_sheet.hr_timesheet_sheet_tree" />
<field name="arch" type="xml">
<tree position="attributes">
<attribute name="delete">0</attribute>
</tree>
</field>
</record>
<record id="hr_timesheet_sheet_tree_my" model="ir.ui.view">
<field name="name">hr_timesheet.sheet.tree.my.delete.cascade</field>
<field name="model">hr_timesheet.sheet</field>
<field
name="inherit_id"
ref="hr_timesheet_sheet.hr_timesheet_sheet_tree_my"
/>
<field name="arch" type="xml">
<tree position="attributes">
<attribute name="delete">0</attribute>
</tree>
</field>
</record>
<record id="view_hr_timesheet_sheet_kanban" model="ir.ui.view">
<field name="name">hr_timesheet.sheet.kanban.delete.cascade</field>
<field name="model">hr_timesheet.sheet</field>
<field
name="inherit_id"
ref="hr_timesheet_sheet.view_hr_timesheet_sheet_kanban"
/>
<field name="arch" type="xml">
<kanban position="attributes">
<attribute name="delete">0</attribute>
</kanban>
</field>
</record>
</odoo>

View file

@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8" ?>
<!--
Copyright 2026 Criptomart
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
-->
<odoo>
<record id="res_config_settings_view_form" model="ir.ui.view">
<field name="name">res.config.settings.form.delete.cascade</field>
<field name="model">res.config.settings</field>
<field name="priority" eval="56" />
<field
name="inherit_id"
ref="hr_timesheet_sheet.res_config_settings_view_form"
/>
<field name="arch" type="xml">
<xpath expr="//div[@name='hr_timesheet_sheet']" position="inside">
<div class="col-xs-12 col-md-6 o_setting_box">
<div class="o_setting_right_pane">
<label for="timesheet_sheet_delete_leave_policy" />
<span
class="fa fa-lg fa-building"
title="Values set here are company-specific."
aria-label="Values set here are company-specific."
groups="base.group_multi_company"
role="img"
/>
<div class="text-muted">
Choose what happens to the time off entries contained
in a timesheet sheet when the sheet is deleted. Keeping
the time off leaves the request untouched and only
detaches its entries from the sheet. Cancelling the time
off refuses and deletes the whole request and restores
the days to the employee balance, including the days
that fall outside the deleted sheet: it cannot be
undone.
</div>
<div class="content-group">
<div class="mt16">
<field
name="timesheet_sheet_delete_leave_policy"
class="o_light_label"
widget="radio"
required="1"
/>
</div>
</div>
</div>
</div>
</xpath>
</field>
</record>
</odoo>

View file

@ -0,0 +1 @@
from . import delete_sheet_wizard

View file

@ -0,0 +1,152 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from markupsafe import Markup
from odoo import _, api, fields, models
from ..models.res_company import LEAVE_POLICY_SELECTION
class HrTimesheetSheetDeleteWizard(models.TransientModel):
_name = "hr_timesheet.sheet.delete.wizard"
_description = "Delete Timesheet Sheets"
sheet_ids = fields.Many2many(
comodel_name="hr_timesheet.sheet",
string="Timesheet Sheets",
)
sheet_count = fields.Integer(
string="Sheets",
compute="_compute_delete_cascade_data",
)
timesheet_count = fields.Integer(
string="Timesheet Entries",
compute="_compute_delete_cascade_data",
)
timesheet_hours = fields.Float(
string="Hours",
compute="_compute_delete_cascade_data",
)
invoiced_count = fields.Integer(
string="Invoiced Entries",
compute="_compute_delete_cascade_data",
)
leave_policy = fields.Selection(
selection=LEAVE_POLICY_SELECTION,
string="Time Off Policy",
compute="_compute_delete_cascade_data",
)
leave_ids = fields.Many2many(
comodel_name="hr.leave",
relation="hr_timesheet_sheet_delete_wizard_leave_rel",
string="Time Off To Cancel",
compute="_compute_delete_cascade_data",
)
leave_count = fields.Integer(
string="Time Off Entries",
compute="_compute_delete_cascade_data",
)
leave_days = fields.Float(
string="Days Restored",
compute="_compute_delete_cascade_data",
)
partial_leave_ids = fields.Many2many(
comodel_name="hr.leave",
relation="hr_timesheet_sheet_delete_wizard_partial_leave_rel",
string="Time Off Beyond The Sheet Period",
compute="_compute_delete_cascade_data",
)
blocking_reason = fields.Text(
string="Blocking Reason",
compute="_compute_delete_cascade_data",
)
summary_html = fields.Html(
string="Summary",
compute="_compute_delete_cascade_data",
sanitize=False,
)
@api.model
def default_get(self, fields_list):
res = super().default_get(fields_list)
if self.env.context.get("active_model") == "hr_timesheet.sheet":
res["sheet_ids"] = [(6, 0, self.env.context.get("active_ids") or [])]
return res
@api.depends("sheet_ids")
def _compute_delete_cascade_data(self):
for wizard in self:
sheets = wizard.sheet_ids
data = sheets._delete_cascade_prepare()
wizard.sheet_count = data["sheet_count"]
wizard.timesheet_count = data["timesheet_count"]
wizard.timesheet_hours = data["timesheet_hours"]
wizard.invoiced_count = data["invoiced_count"]
wizard.leave_policy = data["leave_policy"]
wizard.leave_count = data["leave_count"]
wizard.leave_days = data["leave_days"]
cancel_sheets = sheets._delete_cascade_cancel_sheets()
wizard.leave_ids = cancel_sheets._sheet_leaves_to_cancel().ids
wizard.partial_leave_ids = cancel_sheets._sheet_leaves_beyond_period().ids
wizard.blocking_reason = "\n".join(data["blocking"]) or False
wizard.summary_html = wizard._build_summary_html(data)
def _build_summary_html(self, data):
self.ensure_one()
if not data["sheet_count"]:
return Markup("<p>%s</p>") % _("No timesheet sheet was selected.")
parts = []
for section in data["sections"]:
parts.append(Markup("<h4>%s</h4>") % section["title"])
parts.append(
Markup("<ul>%s</ul>")
% Markup("").join(
Markup("<li>%s</li>") % line for line in section["lines"]
)
)
return Markup("").join(parts)
def _delete_cascade_list_action_xml_id(self):
"""Hook for extensions
The list the user plausibly comes from. Deleting the sheet of another
employee requires *Timesheets / Administrator*, and that sheet is only
listed by *All Timesheet Sheets*; every other case is a user looking at
his own sheets, and *My Timesheet Sheets* is the only list of sheets an
employee outside that group has a menu for.
"""
self.ensure_one()
own_sheets = self.sheet_ids.user_id == self.env.user
is_manager = self.env.user.has_group("hr_timesheet.group_timesheet_manager")
if own_sheets or not is_manager:
return "hr_timesheet_sheet.act_hr_timesheet_sheet_my_timesheets"
return "hr_timesheet_sheet.act_hr_timesheet_sheet_all_timesheets"
def _delete_cascade_next_action(self):
"""Hook for extensions
Called before the deletion, while the sheets can still be read.
Opened from a list, the caller is left on that list: closing the dialog
reloads it and keeps its filters, its grouping and its selection, and
there is no dead record on screen. Opened from the form of a sheet that
no longer exists, closing the dialog would leave that form on screen
with the values of a deleted record: return the list of sheets instead,
with ``target="main"`` so the deleted form is dropped from the
breadcrumb rather than kept one step behind.
"""
self.ensure_one()
if not self.env.context.get("sheet_delete_cascade_from_form"):
return {"type": "ir.actions.act_window_close"}
action = self.env["ir.actions.act_window"]._for_xml_id(
self._delete_cascade_list_action_xml_id()
)
action["target"] = "main"
return action
def action_confirm_delete(self):
self.ensure_one()
next_action = self._delete_cascade_next_action()
self.sheet_ids._with_delete_cascade().unlink()
return next_action

View file

@ -0,0 +1,65 @@
<?xml version="1.0" encoding="utf-8" ?>
<!--
Copyright 2026 Criptomart
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
-->
<odoo>
<record id="hr_timesheet_sheet_delete_wizard_form" model="ir.ui.view">
<field name="name">hr_timesheet.sheet.delete.wizard.form</field>
<field name="model">hr_timesheet.sheet.delete.wizard</field>
<field name="arch" type="xml">
<form string="Delete Timesheet Sheets">
<field name="sheet_ids" invisible="1" />
<field name="invoiced_count" invisible="1" />
<group>
<field name="sheet_count" />
<field name="timesheet_count" />
<field name="timesheet_hours" widget="float_time" />
</group>
<field name="summary_html" nolabel="1" readonly="1" />
<div
class="alert alert-danger"
role="alert"
attrs="{'invisible': [('blocking_reason', '!=', False)]}"
>
This cannot be undone. The selected sheets and the timesheet
entries they contain will be permanently deleted.
</div>
<div
class="alert alert-warning"
role="alert"
attrs="{'invisible': [('blocking_reason', '=', False)]}"
>
<field name="blocking_reason" nolabel="1" readonly="1" />
</div>
<footer>
<button
name="action_confirm_delete"
string="Delete permanently"
type="object"
class="btn-danger"
attrs="{'invisible': ['|', ('blocking_reason', '!=', False), ('sheet_count', '=', 0)]}"
/>
<button string="Cancel" class="btn-secondary" special="cancel" />
</footer>
</form>
</field>
</record>
<record id="action_hr_timesheet_sheet_delete_wizard" model="ir.actions.act_window">
<field name="name">Delete Timesheet Sheets</field>
<field name="res_model">hr_timesheet.sheet.delete.wizard</field>
<field name="view_mode">form</field>
<field name="view_id" ref="hr_timesheet_sheet_delete_wizard_form" />
<field name="target">new</field>
<field
name="binding_model_id"
ref="hr_timesheet_sheet.model_hr_timesheet_sheet"
/>
<!--
Not bound to the form view: there the Delete Sheet button of the
header is the only entry point, and it is the one that tells the
wizard the sheet form has to be left behind once the sheet is gone.
-->
<field name="binding_view_types">list,kanban</field>
</record>
</odoo>

View file

@ -0,0 +1,17 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
{
"name": "HR Timesheet Sheet - Time Off",
"version": "16.0.1.0.0",
"category": "Human Resources",
"summary": "Make time off entries inert but visible inside timesheet sheets",
"author": "Criptomart, Odoo Community Association (OCA)",
"website": "https://github.com/OCA/timesheet",
"license": "AGPL-3",
"depends": ["hr_timesheet_sheet", "project_timesheet_holidays"],
"data": [],
"installable": True,
"auto_install": True,
"development_status": "Beta",
}

View file

@ -0,0 +1,41 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * hr_timesheet_sheet_holidays
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 16.0\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: \n"
"Last-Translator: \n"
"Language-Team: \n"
"Language: es\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#. module: hr_timesheet_sheet_holidays
#: model:ir.model,name:hr_timesheet_sheet_holidays.model_account_analytic_line
msgid "Analytic Line"
msgstr "Línea analítica"
#. module: hr_timesheet_sheet_holidays
#. odoo-python
#: code:addons/hr_timesheet_sheet_holidays/models/account_analytic_line.py:0
#, python-format
msgid ""
"Time off hours are managed from the Time Off application and cannot be "
"changed from a timesheet sheet.\n"
"Sheet: %(sheet)s\n"
"Day: %(day)s"
msgstr ""
"Las horas de una ausencia se gestionan desde la aplicación Ausencias y no se "
"pueden modificar desde una hoja de servicio.\n"
"Hoja: %(sheet)s\n"
"Día: %(day)s"
#. module: hr_timesheet_sheet_holidays
#: model:ir.model,name:hr_timesheet_sheet_holidays.model_hr_timesheet_sheet
msgid "Timesheet Sheet"
msgstr "Hoja de servicio"

View file

@ -0,0 +1,35 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * hr_timesheet_sheet_holidays
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 16.0\n"
"Report-Msgid-Bugs-To: \n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: hr_timesheet_sheet_holidays
#: model:ir.model,name:hr_timesheet_sheet_holidays.model_account_analytic_line
msgid "Analytic Line"
msgstr ""
#. module: hr_timesheet_sheet_holidays
#. odoo-python
#: code:addons/hr_timesheet_sheet_holidays/models/account_analytic_line.py:0
#, python-format
msgid ""
"Time off hours are managed from the Time Off application and cannot be "
"changed from a timesheet sheet.\n"
"Sheet: %(sheet)s\n"
"Day: %(day)s"
msgstr ""
#. module: hr_timesheet_sheet_holidays
#: model:ir.model,name:hr_timesheet_sheet_holidays.model_hr_timesheet_sheet
msgid "Timesheet Sheet"
msgstr ""

View file

@ -0,0 +1,2 @@
from . import account_analytic_line
from . import hr_timesheet_sheet

View file

@ -0,0 +1,74 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import _, models
from odoo.exceptions import UserError
from odoo.tools.misc import format_date
# The context of an RPC call is controlled by the client: odoo.api.call_kw
# rebuilds the environment with whatever dictionary arrives in the request, so
# a flag carrying a JSON value could be forged by any authenticated user. These
# sentinels are compared by identity and cannot be produced by a deserializer,
# so they only ever reach the consumer from inside this process.
_SHEET_LINK_WRITE = object()
_SHEET_RECOMPUTE = object()
class AccountAnalyticLine(models.Model):
_inherit = "account.analytic.line"
def _sheet_neutral_fields(self):
"""Hook for extensions"""
return {"sheet_id"}
def _sheet_link_write_token(self):
"""Hook for extensions"""
return _SHEET_LINK_WRITE
def _sheet_recompute_token(self):
"""Hook for extensions"""
return _SHEET_RECOMPUTE
def _in_sheet_link_write(self):
return (
self.env.context.get("sheet_link_write") is self._sheet_link_write_token()
)
def _detach_leave_lines_on_unlink(self):
"""Hook for extensions"""
return self.env.context.get("sheet_recompute") is self._sheet_recompute_token()
def _check_can_update_timesheet(self):
if self._in_sheet_link_write():
return True
if not self.env.su and self.filtered("holiday_id"):
return False
return super()._check_can_update_timesheet()
def write(self, vals):
if vals and self.filtered("holiday_id"):
if set(vals) <= self._sheet_neutral_fields():
linking = self.with_context(
sheet_link_write=self._sheet_link_write_token()
)
return super(AccountAnalyticLine, linking).write(vals)
if not self._check_can_update_timesheet():
line = self.filtered("holiday_id")[:1]
raise UserError(
_(
"Time off hours are managed from the Time Off application"
" and cannot be changed from a timesheet sheet.\n"
"Sheet: %(sheet)s\n"
"Day: %(day)s",
sheet=line.sheet_id.complete_name or "-",
day=format_date(self.env, line.date),
)
)
return super().write(vals)
def unlink(self):
leave_lines = self.filtered("holiday_id")
if leave_lines and leave_lines._detach_leave_lines_on_unlink():
leave_lines.write({"sheet_id": False})
return super(AccountAnalyticLine, self - leave_lines).unlink()
return super().unlink()

View file

@ -0,0 +1,21 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import api, models
class Sheet(models.Model):
_inherit = "hr_timesheet.sheet"
def _with_sheet_recompute(self):
"""Hook for extensions"""
token = self.env["account.analytic.line"]._sheet_recompute_token()
return self.with_context(sheet_recompute=token)
@api.model_create_multi
def create(self, vals_list):
sheets = super(Sheet, self._with_sheet_recompute()).create(vals_list)
return sheets.with_env(self.env)
def write(self, vals):
return super(Sheet, self._with_sheet_recompute()).write(vals)

View file

@ -0,0 +1,3 @@
* Criptomart
* Luis Díez <luis@criptomart.net>

View file

@ -0,0 +1,24 @@
``hr_timesheet_sheet`` works on every analytic line of a period at once, while
``project_timesheet_holidays`` protects the analytic lines it generates for validated
time off against any modification or deletion. Together they make any timesheet sheet
that spans a day with validated time off unmanageable: opening, recomputing, creating or
saving such a sheet raises a ``UserError`` and the employee cannot record their hours.
This bridge module makes time off lines inert with respect to the bulk operations of the
sheet while keeping them visible inside it, so that the week is shown complete with its
time off hours included. Two different code paths are involved and both are handled: the
bulk ``write`` that links or unlinks ``sheet_id``, and the ``Command.DELETE`` the web
client emits for the lines that leave the set cached by the onchange, which ends up in a
real ``unlink()``. In the second case the time off line is detached from the sheet
(``sheet_id = False``) instead of being deleted; the time off request and the employee
balance are never touched.
The protection itself is not relaxed: the hours, date, project, task, employee and
description of a time off line still cannot be changed from a timesheet sheet, the line
still cannot be deleted by hand and new timesheets still cannot be created on a time off
task. Only a write that touches nothing but the neutral link fields is let through.
The module also closes a pre-existing hole in ``hr_timesheet_sheet``, whose
``_check_can_update_timesheet()`` returned ``True`` for any recordset without
``sheet_id`` and therefore silently disabled the core protection for every time off line
not linked to a sheet.

View file

@ -0,0 +1,5 @@
* The 2D matrix widget cannot make a single row read-only, so the time off row looks
editable even though any attempt to change it is rejected. Making the cell read-only in
the client would require an extension of ``web_widget_x2many_2d_matrix``.
* Lines generated from public holidays (``global_leave_id``) are not covered: they are
not protected by ``project_timesheet_holidays`` and therefore never blocked the sheet.

View file

@ -0,0 +1,39 @@
Nothing to configure. Once installed, a timesheet sheet covering a week with validated
time off can be opened, recomputed, created and saved by the employee, and the time off
lines are shown in the *Summary* matrix and in the *Details* tab with their project, task
and hours, adding up to the total of the sheet.
Time off hours are still read-only from the sheet. Trying to change them raises an error
pointing the user to the *Time Off* application, naming the sheet and the day concerned.
To change them, cancel or modify the time off request in the *Time Off* application.
A time off line may end up detached from every sheet (``sheet_id = False``) when the
period or the employee of a sheet is changed in the form. Nothing is lost: the line, the
time off request and the balance are untouched, and the line is linked again as soon as a
sheet covering that day is recomputed.
For developers, the module publishes two hooks on ``account.analytic.line``:
* ``_sheet_neutral_fields()`` returns the set of fields whose write is considered a pure
sheet link operation (``{"sheet_id"}`` by default). Widening it requires reviewing the
protection of time off hours.
* ``_detach_leave_lines_on_unlink()`` returns whether an ``unlink()`` of time off lines
must detach them instead of deleting them.
Both behaviours are driven by the internal context flags ``sheet_link_write`` and
``sheet_recompute``. The context of an RPC call is controlled by the client, so those
flags do **not** carry a boolean: they carry a module-level sentinel object, obtained
from ``_sheet_link_write_token()`` / ``_sheet_recompute_token()`` and compared by
identity. A JSON deserializer cannot produce such an object, so the flags cannot be
forged from outside the process. Never replace a token with a value that survives
serialization, and never set either flag from a view, an action or a user context.
Three tests are mandatory regression tests for any change to those flags:
* ``test_no_context_leak_after_sheet_operation`` — the flag does not escape: right after
a sheet operation, in the same transaction, a direct write or unlink on a time off line
is blocked again.
* ``test_forged_sheet_link_write_flag_does_not_unlock_write`` and
``test_forged_sheet_recompute_flag_does_not_unlock_unlink`` — the flag cannot be
injected: a boolean sent through ``odoo.api.call_kw``, the entry point the web client
uses, unlocks nothing.

View file

@ -0,0 +1 @@
from . import test_hr_timesheet_sheet_holidays

View file

@ -0,0 +1,509 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
import json
from datetime import date, datetime
from odoo.api import call_kw
from odoo.exceptions import UserError
from odoo.tests import tagged
from odoo.tests.common import Form, TransactionCase
CORE_WRITE_ERROR = "You cannot modify timesheets that are linked to time off requests"
@tagged("post_install", "-at_install")
class TestHrTimesheetSheetHolidays(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.env = cls.env(
context=dict(
cls.env.context,
tracking_disable=True,
no_reset_password=True,
mail_create_nolog=True,
mail_notrack=True,
)
)
cls.week_start = date(2026, 9, 7)
cls.week_end = date(2026, 9, 13)
cls.previous_week_start = date(2026, 8, 31)
cls.previous_week_end = date(2026, 9, 6)
cls.leave_start = datetime(2026, 9, 7, 6, 0, 0)
cls.leave_end = datetime(2026, 9, 11, 18, 0, 0)
cls.company = cls.env["res.company"].create({"name": "Timesheet Holidays Co"})
cls.env.user.write({"company_ids": [(4, cls.company.id)]})
cls.company.resource_calendar_id.tz = "UTC"
cls.internal_project = cls.company.internal_project_id
cls.timeoff_task = cls.company.leave_timesheet_task_id
cls.user = cls._new_user("tsh_holidays_employee")
cls.user_other = cls._new_user("tsh_holidays_other")
cls.user_third = cls._new_user("tsh_holidays_third")
# Only a timesheet manager can open a sheet on behalf of another
# employee, so this is the role that reaches the "change the employee"
# sequence of R2. It is not a superuser: env.su stays False and the
# time off protection is fully active for it.
cls.user_manager = cls._new_user(
"tsh_holidays_manager", ("hr_timesheet.group_timesheet_manager",)
)
cls.employee = cls._new_employee("Sheet Employee", cls.user)
cls.employee_other = cls._new_employee("Other Employee", cls.user_other)
cls.employee_third = cls._new_employee("Third Employee", cls.user_third)
cls.project = cls.env["project.project"].create(
{
"name": "Timesheet Holidays Project",
"company_id": cls.company.id,
"allow_timesheets": True,
"is_timesheet_task_required": False,
}
)
cls.leave_type = cls.env["hr.leave.type"].create(
{
"name": "Paid Time Off (test)",
"company_id": cls.company.id,
"requires_allocation": "yes",
"allocation_validation_type": "no",
"leave_validation_type": "no_validation",
"timesheet_generate": True,
"timesheet_project_id": cls.internal_project.id,
"timesheet_task_id": cls.timeoff_task.id,
}
)
cls._allocate(cls.employee)
cls._allocate(cls.employee_third)
cls.leave = cls._request_leave(cls.employee, cls.leave_start, cls.leave_end)
cls.leave_third = cls._request_leave(
cls.employee_third, cls.leave_start, cls.leave_end
)
cls.leave_lines = cls.leave.sudo().timesheet_ids
cls.leave_lines_third = cls.leave_third.sudo().timesheet_ids
cls.sheet_third = cls.env["hr_timesheet.sheet"].create(
{
"employee_id": cls.employee_third.id,
"company_id": cls.company.id,
"date_start": cls.week_start,
"date_end": cls.week_end,
}
)
cls.sheet_third._compute_timesheet_ids()
@classmethod
def _new_user(cls, login, extra_groups=()):
return (
cls.env["res.users"]
.with_context(no_reset_password=True, default_role_line_ids=[])
.create(
{
"name": login,
"login": login,
"email": "%s@example.org" % login,
"company_id": cls.company.id,
"company_ids": [(6, 0, [cls.company.id])],
"groups_id": [
(
6,
0,
[
cls.env.ref("hr_timesheet.group_hr_timesheet_user").id,
cls.env.ref("project.group_project_user").id,
# Third party add-ons read project_id.stage_id
# when a timesheet line is written, and that
# field is protected by this group.
cls.env.ref("project.group_project_stages").id,
]
+ [cls.env.ref(xmlid).id for xmlid in extra_groups],
)
],
}
)
)
@classmethod
def _new_employee(cls, name, user):
return cls.env["hr.employee"].create(
{"name": name, "user_id": user.id, "company_id": cls.company.id}
)
@classmethod
def _allocate(cls, employee):
allocation = cls.env["hr.leave.allocation"].create(
{
"name": "Allocation for %s" % employee.name,
"holiday_status_id": cls.leave_type.id,
"employee_id": employee.id,
"number_of_days": 25,
"date_from": date(2026, 1, 1),
}
)
if allocation.state != "validate":
allocation.action_validate()
return allocation
@classmethod
def _request_leave(cls, employee, date_from, date_to):
return cls.env["hr.leave"].create(
{
"name": "Time off for %s" % employee.name,
"holiday_status_id": cls.leave_type.id,
"employee_id": employee.id,
"date_from": date_from,
"date_to": date_to,
"number_of_days": 5,
}
)
# Helpers
def _sheet_form(self, user=None):
return Form(self.env["hr_timesheet.sheet"].with_user(user or self.user))
def _leaves_taken(self, employee):
leave_type = self.leave_type.with_context(employee_id=employee.id)
leave_type.invalidate_recordset()
return leave_type.leaves_taken
def _assert_leave_lines_survived(self, lines, leaves_taken_before):
self.assertEqual(lines.exists(), lines)
self.assertFalse(lines.mapped("sheet_id"))
self.assertEqual(self.leave.state, "validate")
self.assertEqual(self._leaves_taken(self.employee), leaves_taken_before)
def _assert_leave_line_is_protected(self, line, user):
line = line.with_user(user)
with self.assertRaises(UserError):
line.write({"unit_amount": 3.0})
self.env.invalidate_all()
with self.assertRaises(UserError):
line.unlink()
self.env.invalidate_all()
self.assertFalse(self.env.context.get("sheet_link_write"))
self.assertFalse(self.env.context.get("sheet_recompute"))
# Fixture sanity
def test_fixture_generated_leave_lines(self):
self.assertEqual(len(self.leave_lines), 5)
self.assertEqual(self.leave.state, "validate")
self.assertEqual(self.leave_lines.mapped("task_id"), self.timeoff_task)
# Published hooks
def test_sheet_neutral_fields_default(self):
lines = self.env["account.analytic.line"]
self.assertEqual(lines._sheet_neutral_fields(), {"sheet_id"})
def test_detach_leave_lines_on_unlink_hook(self):
lines = self.env["account.analytic.line"]
self.assertFalse(lines._detach_leave_lines_on_unlink())
recomposing = lines.with_context(
sheet_recompute=lines._sheet_recompute_token()
)
self.assertTrue(recomposing._detach_leave_lines_on_unlink())
def test_context_tokens_are_not_serializable(self):
lines = self.env["account.analytic.line"]
for token in (lines._sheet_link_write_token(), lines._sheet_recompute_token()):
with self.assertRaises(TypeError):
json.dumps(token)
# RF-01
def test_sheet_recompute_with_leave_lines(self):
sheet = (
self.env["hr_timesheet.sheet"]
.with_user(self.user)
.create(
{
"employee_id": self.employee.id,
"company_id": self.company.id,
"date_start": self.week_start,
"date_end": self.week_end,
}
)
)
sheet._compute_timesheet_ids()
self.assertTrue(sheet.timesheet_ids.filtered("holiday_id"))
self.assertEqual(
sheet.timesheet_ids.filtered("holiday_id"), self.leave_lines
)
self.assertEqual(self.leave_lines.mapped("sheet_id"), sheet.sudo())
sheet.write({"timesheet_ids": [(6, 0, sheet.timesheet_ids.ids)]})
self.assertTrue(sheet.timesheet_ids.filtered("holiday_id"))
def test_form_create_over_leave_week(self):
form = self._sheet_form()
form.employee_id = self.employee
form.date_start = self.week_start
form.date_end = self.week_end
sheet = form.save()
self.assertEqual(len(sheet.timesheet_ids.filtered("holiday_id")), 5)
self.assertEqual(self.leave_lines.mapped("sheet_id"), sheet.sudo())
# RF-02
def test_form_create_moving_period_keeps_leave_lines(self):
taken = self._leaves_taken(self.employee)
form = self._sheet_form()
form.employee_id = self.employee
form.date_start = self.week_start
form.date_end = self.week_end
form.date_start = self.previous_week_start
form.date_end = self.previous_week_end
sheet = form.save()
self.assertFalse(sheet.timesheet_ids.filtered("holiday_id"))
self._assert_leave_lines_survived(self.leave_lines, taken)
def test_form_create_changing_employee_keeps_leave_lines(self):
taken = self._leaves_taken(self.employee)
self.assertFalse(self.env(user=self.user_manager).su)
form = self._sheet_form(self.user_manager)
form.employee_id = self.employee
form.date_start = self.week_start
form.date_end = self.week_end
form.employee_id = self.employee_other
sheet = form.save()
self.assertEqual(sheet.employee_id, self.employee_other)
self.assertFalse(sheet.timesheet_ids.filtered("holiday_id"))
self._assert_leave_lines_survived(self.leave_lines, taken)
def test_form_create_shrinking_period_keeps_leave_lines(self):
taken = self._leaves_taken(self.employee)
kept_day = date(2026, 9, 8)
form = self._sheet_form()
form.employee_id = self.employee
form.date_start = self.week_start
form.date_end = self.week_end
form.date_end = kept_day
sheet = form.save()
dropped = self.leave_lines.filtered(lambda line: line.date > kept_day)
self.assertTrue(dropped)
self.assertEqual(dropped.exists(), dropped)
self.assertFalse(dropped.mapped("sheet_id"))
self.assertEqual(
sheet.timesheet_ids.filtered("holiday_id"),
(self.leave_lines - dropped),
)
self.assertEqual(self.leave.state, "validate")
self.assertEqual(self._leaves_taken(self.employee), taken)
def test_unlink_with_sheet_recompute_detaches_leave_lines(self):
sheet = self._create_linked_sheet()
lines = self.leave_lines
lines.with_user(self.user).with_context(
sheet_recompute=lines._sheet_recompute_token()
).unlink()
self.assertEqual(lines.exists(), lines)
self.assertFalse(lines.mapped("sheet_id"))
self.assertFalse(sheet.timesheet_ids.filtered("holiday_id"))
def test_unlink_with_sheet_recompute_deletes_regular_lines(self):
sheet = self._create_linked_sheet()
regular = self._create_regular_line()
sheet._compute_timesheet_ids()
mixed = (self.leave_lines | regular.sudo()).with_user(self.user)
mixed.with_context(sheet_recompute=mixed._sheet_recompute_token()).unlink()
self.assertFalse(regular.exists())
self.assertEqual(self.leave_lines.exists(), self.leave_lines)
# RF-03
def test_leave_line_hours_are_protected(self):
self._create_linked_sheet()
line = self.leave_lines[0]
self.assertTrue(line.sheet_id)
with self.assertRaises(UserError):
line.with_user(self.user).write({"unit_amount": 99.0})
self.env.invalidate_all()
self.assertNotEqual(line.unit_amount, 99.0)
def test_leave_line_without_sheet_hours_are_protected(self):
line = self.leave_lines[0]
self.assertFalse(line.sheet_id)
self.assertFalse(line.with_user(self.user)._check_can_update_timesheet())
with self.assertRaises(UserError):
line.with_user(self.user).write({"unit_amount": 99.0})
self.env.invalidate_all()
self.assertNotEqual(line.unit_amount, 99.0)
def test_leave_line_hours_protected_inside_sheet_operation(self):
line = self.leave_lines[0].with_user(self.user)
with self.assertRaises(UserError):
line.with_context(
sheet_recompute=line._sheet_recompute_token()
).write({"unit_amount": 99.0})
self.env.invalidate_all()
self.assertNotEqual(self.leave_lines[0].unit_amount, 99.0)
def test_leave_line_mixed_write_is_protected(self):
sheet = self._create_linked_sheet()
line = self.leave_lines[0].with_user(self.user)
with self.assertRaises(UserError):
line.write({"sheet_id": sheet.id, "unit_amount": 99.0})
self.env.invalidate_all()
self.assertNotEqual(self.leave_lines[0].unit_amount, 99.0)
def test_leave_line_other_fields_are_protected(self):
line = self.leave_lines[0].with_user(self.user)
for vals in (
{"date": date(2026, 9, 12)},
{"name": "hacked"},
{"project_id": self.project.id},
{"employee_id": self.employee_other.id},
):
self.env.invalidate_all()
with self.assertRaises(UserError):
line.write(vals)
def test_leave_line_cannot_be_deleted(self):
self._create_linked_sheet()
line = self.leave_lines[0]
with self.assertRaises(UserError):
line.with_user(self.user).unlink()
self.env.invalidate_all()
self.assertTrue(line.exists())
def test_cannot_create_timesheet_on_timeoff_task(self):
with self.assertRaises(UserError):
self.env["account.analytic.line"].with_user(self.user).create(
{
"name": "sneaky",
"project_id": self.internal_project.id,
"task_id": self.timeoff_task.id,
"employee_id": self.employee.id,
"date": self.week_start,
"unit_amount": 4.0,
}
)
# RF-04
def test_leave_line_visible_in_matrix(self):
sheet = self._create_linked_sheet()
self._create_regular_line()
sheet._compute_timesheet_ids()
sheet._compute_line_ids()
leave_rows = sheet.line_ids.filtered(
lambda line: line.project_id == self.internal_project
)
self.assertTrue(leave_rows)
self.assertTrue(sum(leave_rows.mapped("unit_amount")) > 0)
self.assertEqual(
sum(sheet.timesheet_ids.mapped("unit_amount")), sheet.total_time
)
self.assertTrue(sheet.total_time >= sum(self.leave_lines.mapped("unit_amount")))
# RF-05
def test_leave_line_error_message(self):
sheet = self._create_linked_sheet()
line = self.leave_lines[0]
with self.assertRaises(UserError) as catcher:
line.with_user(self.user).write({"unit_amount": 99.0})
message = str(catcher.exception)
self.assertNotIn(CORE_WRITE_ERROR, message)
self.assertIn("Time Off application", message)
self.assertIn(sheet.complete_name, message)
self.assertIn("Day:", message)
# RF-06
def test_forged_sheet_link_write_flag_does_not_unlock_write(self):
line = self.leave_lines[0]
model = self.env["account.analytic.line"].with_user(self.user)
with self.assertRaises(UserError):
call_kw(
model,
"write",
[[line.id], {"unit_amount": 99.0}],
{"context": {"sheet_link_write": True}},
)
self.env.invalidate_all()
self.assertNotEqual(self.leave_lines[0].unit_amount, 99.0)
self.assertFalse(
model.with_context(sheet_link_write=True)._in_sheet_link_write()
)
def test_forged_sheet_recompute_flag_does_not_unlock_unlink(self):
sheet = self._create_linked_sheet()
line = self.leave_lines[0]
model = self.env["account.analytic.line"].with_user(self.user)
with self.assertRaises(UserError):
call_kw(
model,
"unlink",
[[line.id]],
{"context": {"sheet_recompute": True}},
)
self.env.invalidate_all()
self.assertTrue(line.exists())
self.assertEqual(line.sheet_id, sheet.sudo())
self.assertFalse(
model.with_context(
sheet_recompute=True
)._detach_leave_lines_on_unlink()
)
def test_no_context_leak_after_sheet_operation(self):
form = self._sheet_form()
form.employee_id = self.employee
form.date_start = self.week_start
form.date_end = self.week_end
form.date_start = self.previous_week_start
form.date_end = self.previous_week_end
sheet = form.save()
self.assertFalse(sheet.env.context.get("sheet_recompute"))
self.assertFalse(sheet.env.context.get("sheet_link_write"))
self._assert_leave_line_is_protected(self.leave_lines[0], self.user)
self._assert_leave_line_is_protected(
self.leave_lines_third[0], self.user_third
)
self.sheet_third.with_user(self.user_third).write(
{"timesheet_ids": [(6, 0, self.sheet_third.timesheet_ids.ids)]}
)
self.assertFalse(self.env.context.get("sheet_recompute"))
self.assertFalse(self.env.context.get("sheet_link_write"))
self._assert_leave_line_is_protected(self.leave_lines[0], self.user)
self._assert_leave_line_is_protected(
self.leave_lines_third[0], self.user_third
)
# Internal helpers
def _create_linked_sheet(self):
sheet = (
self.env["hr_timesheet.sheet"]
.with_user(self.user)
.create(
{
"employee_id": self.employee.id,
"company_id": self.company.id,
"date_start": self.week_start,
"date_end": self.week_end,
}
)
)
sheet._compute_timesheet_ids()
return sheet
def _create_regular_line(self):
return (
self.env["account.analytic.line"]
.with_user(self.user)
.create(
{
"name": "Regular work",
"project_id": self.project.id,
"employee_id": self.employee.id,
"date": self.week_start,
"unit_amount": 4.0,
}
)
)

View file

@ -0,0 +1,2 @@
from . import models
from . import wizards

View file

@ -0,0 +1,23 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
{
"name": "HR Timesheet Sheet - No Review Workflow",
"version": "16.0.1.0.0",
"category": "Human Resources",
"summary": "Hide the timesheet sheet review workflow and unfreeze pending sheets",
"author": "Criptomart, Odoo Community Association (OCA)",
"website": "https://github.com/OCA/timesheet",
"license": "AGPL-3",
"development_status": "Beta",
"depends": ["hr_timesheet_sheet"],
"data": [
"security/ir.model.access.csv",
"wizards/unfreeze_wizard_views.xml",
"views/hr_timesheet_sheet_views.xml",
"views/menus.xml",
],
"installable": True,
"auto_install": False,
"application": False,
}

View file

@ -0,0 +1,144 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * hr_timesheet_sheet_no_review
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 16.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-09-30 16:47+0000\n"
"PO-Revision-Date: 2026-09-30 16:47+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: hr_timesheet_sheet_no_review
#. odoo-python
#: code:addons/hr_timesheet_sheet_no_review/wizards/unfreeze_wizard.py:0
#, python-format
msgid "%s timesheet sheet(s) have been set back to draft."
msgstr ""
#. module: hr_timesheet_sheet_no_review
#: model:ir.model.fields,field_description:hr_timesheet_sheet_no_review.field_hr_timesheet_sheet_unfreeze_wizard__timesheet_hours
msgid "Blocked Hours"
msgstr ""
#. module: hr_timesheet_sheet_no_review
#: model:ir.model.fields,field_description:hr_timesheet_sheet_no_review.field_hr_timesheet_sheet_unfreeze_wizard__timesheet_count
msgid "Blocked Timesheets"
msgstr ""
#. module: hr_timesheet_sheet_no_review
#: model_terms:ir.ui.view,arch_db:hr_timesheet_sheet_no_review.hr_timesheet_sheet_unfreeze_wizard_form
msgid "Cancel"
msgstr ""
#. module: hr_timesheet_sheet_no_review
#: model:ir.model.fields,field_description:hr_timesheet_sheet_no_review.field_hr_timesheet_sheet_unfreeze_wizard__create_uid
msgid "Created by"
msgstr ""
#. module: hr_timesheet_sheet_no_review
#: model:ir.model.fields,field_description:hr_timesheet_sheet_no_review.field_hr_timesheet_sheet_unfreeze_wizard__create_date
msgid "Created on"
msgstr ""
#. module: hr_timesheet_sheet_no_review
#: model:ir.model.fields,field_description:hr_timesheet_sheet_no_review.field_hr_timesheet_sheet_unfreeze_wizard__display_name
msgid "Display Name"
msgstr ""
#. module: hr_timesheet_sheet_no_review
#: model:ir.model.fields,field_description:hr_timesheet_sheet_no_review.field_hr_timesheet_sheet_unfreeze_wizard__sheet_count
msgid "Frozen Sheets"
msgstr ""
#. module: hr_timesheet_sheet_no_review
#: model:ir.model.fields,field_description:hr_timesheet_sheet_no_review.field_hr_timesheet_sheet_unfreeze_wizard__sheet_ids
msgid "Frozen Sheets To Release"
msgstr ""
#. module: hr_timesheet_sheet_no_review
#: model:ir.model.fields,field_description:hr_timesheet_sheet_no_review.field_hr_timesheet_sheet_unfreeze_wizard__id
msgid "ID"
msgstr ""
#. module: hr_timesheet_sheet_no_review
#: model:ir.model.fields,field_description:hr_timesheet_sheet_no_review.field_hr_timesheet_sheet_unfreeze_wizard____last_update
msgid "Last Modified on"
msgstr ""
#. module: hr_timesheet_sheet_no_review
#: model:ir.model.fields,field_description:hr_timesheet_sheet_no_review.field_hr_timesheet_sheet_unfreeze_wizard__write_uid
msgid "Last Updated by"
msgstr ""
#. module: hr_timesheet_sheet_no_review
#: model:ir.model.fields,field_description:hr_timesheet_sheet_no_review.field_hr_timesheet_sheet_unfreeze_wizard__write_date
msgid "Last Updated on"
msgstr ""
#. module: hr_timesheet_sheet_no_review
#. odoo-python
#: code:addons/hr_timesheet_sheet_no_review/wizards/unfreeze_wizard.py:0
#, python-format
msgid "No frozen timesheet sheet was found. Nothing was changed."
msgstr ""
#. module: hr_timesheet_sheet_no_review
#: model:ir.model.fields,field_description:hr_timesheet_sheet_no_review.field_hr_timesheet_sheet_unfreeze_wizard__summary_html
msgid "Summary"
msgstr ""
#. module: hr_timesheet_sheet_no_review
#. odoo-python
#: code:addons/hr_timesheet_sheet_no_review/wizards/unfreeze_wizard.py:0
#, python-format
msgid ""
"The timesheet sheets frozen by the review workflow, and the timesheet "
"entries they keep locked, are:"
msgstr ""
#. module: hr_timesheet_sheet_no_review
#. odoo-python
#: code:addons/hr_timesheet_sheet_no_review/wizards/unfreeze_wizard.py:0
#, python-format
msgid "There is no frozen timesheet sheet. Nothing to unfreeze."
msgstr ""
#. module: hr_timesheet_sheet_no_review
#: model_terms:ir.ui.view,arch_db:hr_timesheet_sheet_no_review.hr_timesheet_sheet_unfreeze_wizard_form
msgid ""
"This is a mass operation on historical data and it cannot be\n"
" undone: the reviewer of every approved sheet is cleared. Make\n"
" sure you have a recent backup before you continue."
msgstr ""
#. module: hr_timesheet_sheet_no_review
#: model:ir.model,name:hr_timesheet_sheet_no_review.model_hr_timesheet_sheet
msgid "Timesheet Sheet"
msgstr ""
#. module: hr_timesheet_sheet_no_review
#: model_terms:ir.ui.view,arch_db:hr_timesheet_sheet_no_review.hr_timesheet_sheet_unfreeze_wizard_form
msgid "Unfreeze"
msgstr ""
#. module: hr_timesheet_sheet_no_review
#. odoo-python
#: code:addons/hr_timesheet_sheet_no_review/wizards/unfreeze_wizard.py:0
#: model:ir.actions.act_window,name:hr_timesheet_sheet_no_review.action_hr_timesheet_sheet_unfreeze_wizard
#: model:ir.ui.menu,name:hr_timesheet_sheet_no_review.menu_hr_timesheet_sheet_unfreeze
#: model_terms:ir.ui.view,arch_db:hr_timesheet_sheet_no_review.hr_timesheet_sheet_unfreeze_wizard_form
#, python-format
msgid "Unfreeze Pending Sheets"
msgstr ""
#. module: hr_timesheet_sheet_no_review
#: model:ir.model,name:hr_timesheet_sheet_no_review.model_hr_timesheet_sheet_unfreeze_wizard
msgid "Unfreeze Pending Timesheet Sheets"
msgstr ""

View file

@ -0,0 +1 @@
from . import hr_timesheet_sheet

View file

@ -0,0 +1,59 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
import logging
from odoo import api, models
from odoo.osv import expression
_logger = logging.getLogger(__name__)
class HrTimesheetSheet(models.Model):
_inherit = "hr_timesheet.sheet"
@api.model
def _unfreeze_domain(self):
"""Hook for extensions: which sheets are considered frozen."""
return [("state", "in", ("confirm", "done"))]
@api.model
def _unfreeze_sheets(self, sheets=None):
"""Send frozen sheets back to draft. Returns the number of sheets affected.
Without ``sheets``, every frozen sheet the current user can see is
released, which is the historical behaviour of this hook. With
``sheets``, only those of them that are still frozen are released: the
wizard passes the recordset it showed the user, so the confirmation
message counts what the warning announced and not what the database
happens to hold by the time the button is pressed.
The update is issued in SQL on purpose: going through ``write()`` would
trigger the sheet recomputation and one tracking message per sheet, on a
volume that is measured in thousands of records.
"""
domain = self._unfreeze_domain()
if sheets is not None:
domain = expression.AND([domain, [("id", "in", sheets.ids)]])
sheets = self.search(domain)
if not sheets:
_logger.info("No frozen timesheet sheets to unfreeze.")
return 0
sheets.check_access_rights("write")
sheets.check_access_rule("write")
self.env.flush_all()
self.env.cr.execute(
"""
UPDATE hr_timesheet_sheet
SET state = 'draft',
reviewer_id = NULL,
write_uid = %s,
write_date = (now() at time zone 'UTC')
WHERE id IN %s
""",
(self.env.uid, tuple(sheets.ids)),
)
count = self.env.cr.rowcount
self.env.invalidate_all(flush=False)
_logger.info("Unfroze %s timesheet sheet(s).", count)
return count

View file

@ -0,0 +1,3 @@
* Criptomart
* Luis Martín <luis@criptomart.net>

View file

@ -0,0 +1,20 @@
``hr_timesheet_sheet`` ships a review workflow: the employee submits the sheet,
a reviewer approves or refuses it and may send it back to draft. Many
organisations never use it — they record hours and do not submit them for
approval.
The workflow is not harmless when it is not used. ``_check_state()`` forbids
modifying the timesheet entries of any sheet that is not in ``new`` or
``draft``. A sheet submitted by mistake and never attended freezes its entries
forever: nobody can edit them, and ``action_timesheet_draft()`` only works from
``done``, never from ``confirm``.
This module removes the review workflow from the user interface — buttons,
kanban actions, search filters, the department counter of sheets to approve
and the menus that only exist to serve it — and adds a wizard that sends the
sheets that got stuck in ``confirm`` or ``done`` back to draft.
The ``action_timesheet_*`` methods of the base module are left untouched, so
the rest of the family (``hr_timesheet_sheet_attendance``,
``hr_timesheet_sheet_policy_*``, ``hr_timesheet_sheet_autodraft``) keeps
working. They simply no longer have an entry point in the interface.

View file

@ -0,0 +1,31 @@
Hiding the review workflow is not the same as forbidding it. The
``action_timesheet_*`` methods stay callable from code, from a server action or
from another module, so a sheet can still go back to ``confirm`` through a
non-interactive path. That is deliberate, for compatibility with the rest of the
family. An installation that needs to really forbid it would need a separate
module.
The modules of the family that take the review workflow for granted lose their
purpose with this one installed. They do not break, because the methods are
still there, but they become unreachable from the user interface:
* ``hr_timesheet_sheet_policy_department_manager``
* ``hr_timesheet_sheet_policy_project_manager``
* ``hr_timesheet_sheet_autodraft``
* the ``action_timesheet_confirm`` check of ``hr_timesheet_sheet_attendance``
Unfreezing cannot be undone. Uninstalling the module brings the buttons back,
but the sheets that were already set back to draft stay in draft, and the four
menus of the review workflow stay deactivated: they belong to the base module
and Odoo does not restore a value written on a foreign record.
The ``Timesheets to Review`` action of the department kanban loses both of its
entry points, but the action record itself survives, because
``ir.actions.act_window`` has no ``active`` field to switch off. It is only
reachable from Settings > Technical > Actions, and its stale
``search_default_to_review`` context key no longer matches any filter.
With the sheets back in ``draft``, every save triggers the base module's
``delete_empty_lines(True)``, which removes lines with no description and no
hours. This is pre-existing behaviour, but it may prune historical empty
entries the first time those sheets are opened.

View file

@ -0,0 +1,35 @@
Once installed, the review buttons are gone from the timesheet sheet form and
kanban; the ``Submitted``, ``To Review`` and ``Reviewer`` entries are gone from
the sheet search view; the ``Not Submitted`` filter is gone from the timesheet
search view; the department kanban no longer counts the sheets to approve; and
the ``To Review`` and ``Timesheets to Submit`` menus are deactivated.
To release the sheets that were left frozen by the review workflow, go to
*Timesheets > Timesheets > Unfreeze Pending Sheets*. The wizard first shows how
many sheets, how many timesheet entries and how many hours are about to be
released. Confirming sets those sheets back to draft and clears their reviewer.
The wizard releases exactly the sheets it listed when it was opened, and the
confirmation message counts them. A sheet frozen by somebody else while the
dialog was on screen is not released: it shows up the next time the wizard is
opened. Only the frozen sheets the user can see are listed, so on a
multi-company database the figures follow the company switcher.
Installing the module never changes the state of a sheet on its own: unfreezing
is always an explicit decision of the timesheet manager. Running the wizard
twice is safe — the second run reports zero sheets and changes nothing.
Take a backup before the first run: the operation touches every frozen sheet at
once and the reviewer of the approved ones is lost.
To decide which sheets count as frozen, override
``hr_timesheet.sheet._unfreeze_domain()``. For instance, an installation that
wants to keep its approved sheets as historical record can narrow it down to
``[("state", "=", "confirm")]``. The domain is applied twice: once to list the
sheets in the dialog, and again when the button is pressed, so a sheet it keeps
out is never released.
``hr_timesheet.sheet._unfreeze_sheets()`` takes an optional recordset. Called
with no argument it releases every frozen sheet, as before; called with a
recordset it releases only those of them that the domain still considers
frozen.

View file

@ -0,0 +1,2 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_hr_timesheet_sheet_unfreeze_wizard,hr_timesheet.sheet.unfreeze.wizard,model_hr_timesheet_sheet_unfreeze_wizard,hr_timesheet.group_timesheet_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_hr_timesheet_sheet_unfreeze_wizard hr_timesheet.sheet.unfreeze.wizard model_hr_timesheet_sheet_unfreeze_wizard hr_timesheet.group_timesheet_manager 1 1 1 1

View file

@ -0,0 +1 @@
from . import test_hr_timesheet_sheet_no_review

View file

@ -0,0 +1,502 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
import os
from unittest.mock import patch
from dateutil.relativedelta import relativedelta
from lxml import etree
from odoo.exceptions import AccessError, UserError
from odoo.modules.module import get_manifest, get_module_path
from odoo.tests.common import Form, TransactionCase
MODULE = "hr_timesheet_sheet_no_review"
REVIEW_ACTIONS = [
"action_timesheet_confirm",
"action_timesheet_done",
"action_timesheet_draft",
"action_timesheet_refuse",
]
REVIEW_MENUS = [
"hr_timesheet_sheet.menu_hr_to_review",
"hr_timesheet_sheet.menu_act_hr_timesheet_sheet_to_review",
"hr_timesheet_sheet.menu_act_hr_timesheet_line_to_submit",
"hr_timesheet_sheet.menu_act_hr_timesheet_line_to_submit_my",
]
class TestHrTimesheetSheetNoReview(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.sheet_model = cls.env["hr_timesheet.sheet"]
cls.line_model = cls.env["account.analytic.line"]
cls.wizard_model = cls.env["hr_timesheet.sheet.unfreeze.wizard"]
cls.company = cls.env["res.company"].create({"name": "No Review Company"})
cls.env.user.company_ids += cls.company
cls.user = (
cls.env["res.users"]
.with_context(no_reset_password=True, default_role_line_ids=[])
.create(
{
"name": "No Review Employee",
"login": "no_review_employee",
"email": "no_review_employee@example.org",
"company_id": cls.company.id,
"company_ids": [(6, 0, [cls.company.id])],
"groups_id": [
(
6,
0,
[
cls.env.ref("hr_timesheet.group_hr_timesheet_user").id,
cls.env.ref("project.group_project_user").id,
],
)
],
}
)
)
cls.manager = (
cls.env["res.users"]
.with_context(no_reset_password=True, default_role_line_ids=[])
.create(
{
"name": "No Review Manager",
"login": "no_review_manager",
"email": "no_review_manager@example.org",
"company_id": cls.company.id,
"company_ids": [(6, 0, [cls.company.id])],
"groups_id": [
(
6,
0,
[
cls.env.ref("hr_timesheet.group_timesheet_manager").id,
cls.env.ref("project.group_project_manager").id,
],
)
],
}
)
)
cls.reviewer = (
cls.env["res.users"]
.with_context(no_reset_password=True, default_role_line_ids=[])
.create(
{
"name": "No Review Reviewer",
"login": "no_review_reviewer",
"email": "no_review_reviewer@example.org",
"company_id": cls.company.id,
"company_ids": [(6, 0, [cls.company.id])],
"groups_id": [
(
6,
0,
[
cls.env.ref("hr.group_hr_user").id,
cls.env.ref("hr_timesheet.group_hr_timesheet_user").id,
cls.env.ref("project.group_project_user").id,
],
)
],
}
)
)
cls.employee = cls.env["hr.employee"].create(
{
"name": "No Review Employee",
"user_id": cls.user.id,
"company_id": cls.company.id,
}
)
cls.env["hr.employee"].create(
{
"name": "No Review Reviewer",
"user_id": cls.reviewer.id,
"company_id": cls.company.id,
}
)
cls.project = cls.env["project.project"].create(
{
"name": "No Review Project",
"company_id": cls.company.id,
"allow_timesheets": True,
}
)
cls.task = cls.env["project.task"].create(
{
"name": "No Review Task",
"project_id": cls.project.id,
"company_id": cls.company.id,
}
)
cls.baseline = cls._frozen_figures(cls.env)
cls.sheet = cls.sheet_model.create(
{"employee_id": cls.employee.id, "company_id": cls.company.id}
)
cls.line = cls.line_model.create(
{
"name": "Frozen entry",
"date": cls.sheet.date_start,
"project_id": cls.project.id,
"task_id": cls.task.id,
"employee_id": cls.employee.id,
"company_id": cls.company.id,
"unit_amount": 3.0,
}
)
cls.sheet.action_timesheet_confirm()
@staticmethod
def _frozen_figures(env):
sheet_model = env["hr_timesheet.sheet"]
sheets = sheet_model.search(sheet_model._unfreeze_domain())
groups = env["account.analytic.line"].read_group(
[("sheet_id", "in", sheets.ids)], ["unit_amount"], []
)
return {
"sheets": len(sheets),
"timesheets": groups[0]["__count"] if groups else 0,
"hours": (groups[0]["unit_amount"] if groups else 0.0) or 0.0,
}
def _arch(self, xml_id, view_type, model="hr_timesheet.sheet"):
view = self.env.ref(xml_id)
return etree.fromstring(
self.env[model].get_view(view_id=view.id, view_type=view_type)["arch"]
)
# RF-01
def test_review_buttons_are_hidden(self):
for xml_id in (
"hr_timesheet_sheet.hr_timesheet_sheet_form",
"hr_timesheet_sheet.hr_timesheet_sheet_form_my",
):
arch = self._arch(xml_id, "form")
for action in REVIEW_ACTIONS:
self.assertFalse(
arch.xpath("//button[@name='%s']" % action),
"%s still exposes %s" % (xml_id, action),
)
state = arch.xpath("//header/field[@name='state']")
self.assertTrue(state)
self.assertEqual(state[0].get("statusbar_visible"), "draft")
def test_review_buttons_are_hidden_in_kanban(self):
arch = self._arch("hr_timesheet_sheet.view_hr_timesheet_sheet_kanban", "kanban")
for action in REVIEW_ACTIONS:
self.assertFalse(arch.xpath("//a[@name='%s']" % action))
def test_reviewer_is_hidden_in_lists(self):
for xml_id in (
"hr_timesheet_sheet.hr_timesheet_sheet_tree",
"hr_timesheet_sheet.hr_timesheet_sheet_tree_my",
):
arch = self._arch(xml_id, "tree")
self.assertFalse(arch.xpath("//field[@name='reviewer_id']"))
self.assertIsNone(arch.xpath("//tree")[0].get("decoration-muted"))
def test_review_filters_are_hidden_in_search(self):
arch = self._arch("hr_timesheet_sheet.view_hr_timesheet_sheet_filter", "search")
self.assertFalse(arch.xpath("//filter[@name='confirm']"))
self.assertFalse(arch.xpath("//filter[@name='to_review']"))
self.assertFalse(arch.xpath("//search/field[@name='reviewer_id']"))
self.assertFalse(arch.xpath("//filter[@name='groupby_reviewer']"))
def test_submission_filter_is_hidden_in_timesheet_search(self):
arch = self._arch(
"hr_timesheet.hr_timesheet_line_search",
"search",
model="account.analytic.line",
)
self.assertFalse(arch.xpath("//filter[@name='unsubmitted']"))
self.assertFalse(
arch.xpath("//search/separator[not(preceding-sibling::filter)]"),
"removing the filter must not leave its separator behind",
)
def test_department_kanban_has_no_review_counter(self):
arch = self._arch(
"hr.hr_department_view_kanban", "kanban", model="hr.department"
)
rendered = etree.tostring(arch).decode()
self.assertNotIn("timesheet_sheet_to_approve_count", rendered)
action = self.env.ref("hr_timesheet_sheet.hr_timesheet_action_from_department")
self.assertFalse(arch.xpath("//a[@name='%s']" % action.id))
def test_review_menus_are_inactive(self):
for xml_id in REVIEW_MENUS:
self.assertFalse(self.env.ref(xml_id).active, xml_id)
def test_review_methods_are_kept(self):
"""The workflow is gone from the interface but still works from code.
The rest of the family (``..._policy_*``, ``..._autodraft``,
``..._attendance``) calls these methods, so they must stay functional.
"""
sheet = self.sheet.with_user(self.reviewer)
self.assertEqual(sheet.state, "confirm")
sheet.action_timesheet_done()
self.assertEqual(sheet.state, "done")
self.assertEqual(sheet.reviewer_id.user_id, self.reviewer)
sheet.action_timesheet_draft()
self.assertEqual(sheet.state, "draft")
self.assertFalse(sheet.reviewer_id)
self.sheet.with_user(self.user).action_timesheet_confirm()
self.assertEqual(sheet.state, "confirm")
sheet.action_timesheet_refuse()
self.assertEqual(sheet.state, "draft")
self.assertFalse(sheet.reviewer_id)
# RF-02
def test_install_does_not_change_sheet_states(self):
manifest = get_manifest(MODULE)
for key in ("pre_init_hook", "post_init_hook", "uninstall_hook", "post_load"):
self.assertFalse(
manifest.get(key), "%s must not declare a %s" % (MODULE, key)
)
module_path = get_module_path(MODULE)
for data_file in manifest["data"]:
with open(os.path.join(module_path, data_file)) as handle:
self.assertNotRegex(
handle.read(),
r"<\s*function",
"%s must not call a method on install" % data_file,
)
records = self.env["ir.model.data"].search([("module", "=", MODULE)])
self.assertTrue(records)
business_models = records.filtered(
lambda record: not record.model.startswith("ir.")
)
self.assertFalse(
business_models.mapped("model"),
"%s must only install technical records" % MODULE,
)
# The figures reach the interface
def test_wizard_reports_pending_sheets_in_the_interface(self):
"""The path the user walks: default_get() + onchange(), no create().
Opening an action with ``target="new"`` never creates the record: the
client builds a virtual one and the server decides what to compute from
the dependency graph. A compute with no ``@api.depends`` is skipped and
the dialog reports zero on a database full of frozen sheets.
"""
form = Form(self.wizard_model)
self.assertEqual(form.sheet_count, self.baseline["sheets"] + 1)
self.assertEqual(form.timesheet_count, self.baseline["timesheets"] + 1)
self.assertAlmostEqual(
form.timesheet_hours, self.baseline["hours"] + 3.0, places=2
)
self.assertNotIn("Nothing to unfreeze", form.summary_html)
self.assertIn(self.sheet, form.sheet_ids)
def test_wizard_reports_pending_sheets_through_raw_onchange(self):
"""Same flow, called the way the web client calls it.
``Form`` is a faithful client, but it is still test code. This drives
``onchange()`` directly with the field list of the real view, so the
test also fails if the view stops exposing ``sheet_ids`` and
``default_get()`` is therefore never asked for it.
"""
arch = self._arch(
"hr_timesheet_sheet_no_review.hr_timesheet_sheet_unfreeze_wizard_form",
"form",
model=self.wizard_model._name,
)
names = [node.get("name") for node in arch.xpath("//field")]
self.assertIn("sheet_ids", names)
values = self.wizard_model.onchange({}, [], {name: "1" for name in names})[
"value"
]
self.assertEqual(values["sheet_count"], self.baseline["sheets"] + 1)
self.assertEqual(values["timesheet_count"], self.baseline["timesheets"] + 1)
self.assertAlmostEqual(
values["timesheet_hours"], self.baseline["hours"] + 3.0, places=2
)
self.assertNotIn("Nothing to unfreeze", values["summary_html"])
def test_wizard_figures_depend_on_a_real_field(self):
"""Guard: the figures must keep a dependency to be computed at all."""
for name in ("sheet_count", "timesheet_count", "timesheet_hours"):
field = self.wizard_model._fields[name]
self.assertEqual(
self.env.registry.field_depends[field],
("sheet_ids",),
"%s must depend on sheet_ids or it is never computed in the UI" % name,
)
def test_wizard_reports_pending_sheets_from_code(self):
"""``create({})`` is the shell path, used by the diagnosis probe."""
wizard = self.wizard_model.create({})
self.assertEqual(wizard.sheet_count, self.baseline["sheets"] + 1)
self.assertEqual(wizard.timesheet_count, self.baseline["timesheets"] + 1)
self.assertAlmostEqual(
wizard.timesheet_hours, self.baseline["hours"] + 3.0, places=2
)
self.assertNotIn("Nothing to unfreeze", wizard.summary_html)
def test_wizard_reports_nothing_to_unfreeze(self):
self.sheet_model._unfreeze_sheets()
form = Form(self.wizard_model)
self.assertEqual(form.sheet_count, 0)
self.assertEqual(form.timesheet_count, 0)
self.assertEqual(form.timesheet_hours, 0.0)
self.assertEqual(len(form.sheet_ids), 0)
self.assertIn("Nothing to unfreeze", form.summary_html)
wizard = form.save()
self.assertIn("Nothing to unfreeze", wizard.summary_html)
# Unfreezing
def test_unfreeze_releases_sheets(self):
self.assertEqual(self.sheet.state, "confirm")
form = Form(self.wizard_model)
expected = form.sheet_count
result = form.save().action_unfreeze()
self.assertEqual(self.sheet.state, "draft")
self.assertFalse(self.sheet.reviewer_id)
self.assertEqual(
self.sheet_model.search_count([("state", "in", ("confirm", "done"))]), 0
)
self.assertIn(str(expected), result["params"]["message"])
def test_unfreeze_only_releases_the_sheets_it_announced(self):
"""The confirmation must count what the warning announced.
The wizard is built, and only then another sheet is frozen. That sheet
was not on screen, so it is not released and it is not counted.
"""
wizard = Form(self.wizard_model).save()
announced = wizard.sheet_count
late_sheet = self.sheet_model.create(
{
"employee_id": self.employee.id,
"company_id": self.company.id,
"date_start": self.sheet.date_start + relativedelta(months=1),
"date_end": self.sheet.date_end + relativedelta(months=1),
}
)
late_sheet.action_timesheet_confirm()
result = wizard.action_unfreeze()
self.assertIn(str(announced), result["params"]["message"])
self.assertEqual(self.sheet.state, "draft")
self.assertEqual(late_sheet.state, "confirm")
def test_unfreeze_ignores_sheets_outside_the_domain(self):
"""Operating on a given recordset must not relax the domain.
``sheet_ids`` comes from the client and can be tampered with. The
sheets that ``_unfreeze_domain()`` keeps out stay out, whatever the
wizard is told to release.
"""
draft_sheet = self.sheet_model.create(
{
"employee_id": self.employee.id,
"company_id": self.company.id,
"date_start": self.sheet.date_start + relativedelta(months=2),
"date_end": self.sheet.date_end + relativedelta(months=2),
}
)
self.assertEqual(draft_sheet.state, "draft")
kept_id = self.sheet.id
with patch.object(
type(self.sheet_model),
"_unfreeze_domain",
lambda records: [
("state", "in", ("confirm", "done")),
("id", "!=", kept_id),
],
):
self.assertNotIn(self.sheet, Form(self.wizard_model).sheet_ids)
wizard = self.wizard_model.create(
{"sheet_ids": [(6, 0, (self.sheet + draft_sheet).ids)]}
)
result = wizard.action_unfreeze()
self.assertEqual(self.sheet.state, "confirm")
self.assertEqual(draft_sheet.state, "draft")
self.assertEqual(result["params"]["type"], "info")
def test_unfreeze_is_idempotent(self):
self.assertTrue(self.sheet_model._unfreeze_sheets())
self.assertEqual(self.sheet_model._unfreeze_sheets(), 0)
result = Form(self.wizard_model).save().action_unfreeze()
self.assertEqual(
self.sheet_model.search_count([("state", "in", ("confirm", "done"))]), 0
)
self.assertEqual(result["params"]["type"], "info")
self.assertEqual(self.sheet.state, "draft")
def test_unfreeze_twice_from_the_interface_is_idempotent(self):
first = Form(self.wizard_model).save()
second = Form(self.wizard_model).save()
self.assertTrue(first.action_unfreeze()["params"]["type"] == "success")
result = second.action_unfreeze()
self.assertEqual(result["params"]["type"], "info")
self.assertEqual(self.sheet.state, "draft")
self.assertFalse(self.sheet.reviewer_id)
def test_unfrozen_lines_are_editable(self):
with self.assertRaises(UserError):
self.line.with_user(self.user).write({"unit_amount": 5.0})
Form(self.wizard_model).save().action_unfreeze()
self.line.with_user(self.user).write({"unit_amount": 5.0})
self.assertEqual(self.line.unit_amount, 5.0)
def test_unfreeze_sheets_hook_keeps_its_signature(self):
"""``_unfreeze_sheets()`` is public: calling it with no argument must
keep releasing every frozen sheet."""
self.assertEqual(
self.sheet_model._unfreeze_sheets(), self.baseline["sheets"] + 1
)
self.assertEqual(self.sheet.state, "draft")
def test_unfreeze_sheets_accepts_a_recordset(self):
self.assertEqual(self.sheet_model._unfreeze_sheets(self.sheet), 1)
self.assertEqual(self.sheet.state, "draft")
self.assertEqual(self.sheet_model._unfreeze_sheets(self.sheet), 0)
no_sheet = self.sheet_model.browse()
self.assertEqual(self.sheet_model._unfreeze_sheets(no_sheet), 0)
def test_unfreeze_domain_hook(self):
self.assertEqual(
self.sheet_model._unfreeze_domain(),
[("state", "in", ("confirm", "done"))],
)
kept_id = self.sheet.id
with patch.object(
type(self.sheet_model),
"_unfreeze_domain",
lambda records: [
("state", "in", ("confirm", "done")),
("id", "!=", kept_id),
],
):
self.sheet_model._unfreeze_sheets()
self.assertEqual(self.sheet.state, "confirm")
self.assertEqual(
self.sheet_model.search_count([("state", "in", ("confirm", "done"))]), 1
)
def test_wizard_is_restricted_to_managers(self):
with self.assertRaises(AccessError):
self.wizard_model.with_user(self.user).create({})
manager_form = Form(self.wizard_model.with_user(self.manager))
self.assertTrue(manager_form.sheet_count)
self.assertTrue(manager_form.save())

View file

@ -0,0 +1,103 @@
<?xml version="1.0" encoding="utf-8" ?>
<!--
Copyright 2026 Criptomart
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
-->
<odoo>
<record id="hr_timesheet_sheet_form" model="ir.ui.view">
<field name="name">hr_timesheet.sheet.form.no.review</field>
<field name="model">hr_timesheet.sheet</field>
<field name="inherit_id" ref="hr_timesheet_sheet.hr_timesheet_sheet_form" />
<field name="arch" type="xml">
<xpath
expr="//header/button[@name='action_timesheet_confirm']"
position="replace"
/>
<xpath
expr="//header/button[@name='action_timesheet_done']"
position="replace"
/>
<xpath
expr="//header/button[@name='action_timesheet_draft']"
position="replace"
/>
<xpath
expr="//header/button[@name='action_timesheet_refuse']"
position="replace"
/>
<xpath expr="//header/field[@name='state']" position="attributes">
<attribute name="statusbar_visible">draft</attribute>
</xpath>
</field>
</record>
<record id="view_hr_timesheet_sheet_kanban" model="ir.ui.view">
<field name="name">hr_timesheet.sheet.kanban.no.review</field>
<field name="model">hr_timesheet.sheet</field>
<field
name="inherit_id"
ref="hr_timesheet_sheet.view_hr_timesheet_sheet_kanban"
/>
<field name="arch" type="xml">
<xpath expr="//a[@name='action_timesheet_confirm']" position="replace" />
<xpath expr="//a[@name='action_timesheet_done']" position="replace" />
<xpath expr="//a[@name='action_timesheet_refuse']" position="replace" />
<xpath expr="//a[@name='action_timesheet_draft']" position="replace" />
</field>
</record>
<record id="hr_timesheet_sheet_tree" model="ir.ui.view">
<field name="name">hr_timesheet.sheet.tree.no.review</field>
<field name="model">hr_timesheet.sheet</field>
<field name="inherit_id" ref="hr_timesheet_sheet.hr_timesheet_sheet_tree" />
<field name="arch" type="xml">
<xpath expr="//tree/field[@name='reviewer_id']" position="replace" />
<xpath expr="//tree" position="attributes">
<attribute name="decoration-muted" />
</xpath>
</field>
</record>
<record id="view_hr_timesheet_sheet_filter" model="ir.ui.view">
<field name="name">hr_timesheet.sheet.filter.no.review</field>
<field name="model">hr_timesheet.sheet</field>
<field
name="inherit_id"
ref="hr_timesheet_sheet.view_hr_timesheet_sheet_filter"
/>
<field name="arch" type="xml">
<xpath expr="//search/filter[@name='confirm']" position="replace" />
<xpath expr="//search/filter[@name='to_review']" position="replace" />
<xpath expr="//search/field[@name='reviewer_id']" position="replace" />
<xpath expr="//filter[@name='groupby_reviewer']" position="replace" />
</field>
</record>
<record id="hr_timesheet_line_search" model="ir.ui.view">
<field name="name">account.analytic.line.search.no.review</field>
<field name="model">account.analytic.line</field>
<field name="inherit_id" ref="hr_timesheet_sheet.hr_timesheet_line_search" />
<field name="arch" type="xml">
<xpath
expr="//search/filter[@name='unsubmitted']/following-sibling::separator[1]"
position="replace"
/>
<xpath expr="//search/filter[@name='unsubmitted']" position="replace" />
</field>
</record>
<record id="hr_department_view_kanban" model="ir.ui.view">
<field name="name">hr.department.kanban.no.review</field>
<field name="model">hr.department</field>
<field name="inherit_id" ref="hr_timesheet_sheet.hr_department_view_kanban" />
<field name="arch" type="xml">
<xpath
expr="//field[@name='timesheet_sheet_to_approve_count']"
position="replace"
/>
<xpath
expr="//div[starts-with(@t-if, 'record.timesheet_sheet_to_approve_count')]"
position="replace"
/>
<xpath
expr="//div[hasclass('o_kanban_manage_reports')]/div[.//t[starts-with(@t-esc, 'record.timesheet_sheet_to_approve')]]"
position="replace"
/>
</field>
</record>
</odoo>

View file

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8" ?>
<!--
Copyright 2026 Criptomart
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
-->
<odoo>
<record id="hr_timesheet_sheet.menu_hr_to_review" model="ir.ui.menu">
<field name="active" eval="False" />
</record>
<record
id="hr_timesheet_sheet.menu_act_hr_timesheet_sheet_to_review"
model="ir.ui.menu"
>
<field name="active" eval="False" />
</record>
<record
id="hr_timesheet_sheet.menu_act_hr_timesheet_line_to_submit"
model="ir.ui.menu"
>
<field name="active" eval="False" />
</record>
<record
id="hr_timesheet_sheet.menu_act_hr_timesheet_line_to_submit_my"
model="ir.ui.menu"
>
<field name="active" eval="False" />
</record>
<menuitem
id="menu_hr_timesheet_sheet_unfreeze"
action="action_hr_timesheet_sheet_unfreeze_wizard"
parent="hr_timesheet.menu_hr_time_tracking"
groups="hr_timesheet.group_timesheet_manager"
sequence="99"
/>
</odoo>

View file

@ -0,0 +1 @@
from . import unfreeze_wizard

View file

@ -0,0 +1,98 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from markupsafe import Markup
from odoo import _, api, fields, models
class HrTimesheetSheetUnfreezeWizard(models.TransientModel):
_name = "hr_timesheet.sheet.unfreeze.wizard"
_description = "Unfreeze Pending Timesheet Sheets"
sheet_ids = fields.Many2many(
comodel_name="hr_timesheet.sheet",
string="Frozen Sheets To Release",
)
sheet_count = fields.Integer(
string="Frozen Sheets",
compute="_compute_frozen_data",
)
timesheet_count = fields.Integer(
string="Blocked Timesheets",
compute="_compute_frozen_data",
)
timesheet_hours = fields.Float(
string="Blocked Hours",
compute="_compute_frozen_data",
)
summary_html = fields.Html(
string="Summary",
compute="_compute_summary_html",
)
@api.model
def default_get(self, fields_list):
res = super().default_get(fields_list)
if "sheet_ids" in fields_list:
sheet_model = self.env["hr_timesheet.sheet"]
sheets = sheet_model.search(sheet_model._unfreeze_domain())
res["sheet_ids"] = [fields.Command.set(sheets.ids)]
return res
@api.depends("sheet_ids")
def _compute_frozen_data(self):
"""The figures describe ``sheet_ids``, not the database.
Depending on a real field is what makes the figures appear in the
interface: opening an action on a transient model builds the record
with ``default_get()`` + ``onchange()``, and that flow seeds every
field of the view into the cache before computing anything. A compute
with no dependency is then a cache hit on the default value, and the
dialog reports zero.
"""
line_model = self.env["account.analytic.line"]
for wizard in self:
sheets = wizard.sheet_ids
groups = line_model.read_group(
[("sheet_id", "in", sheets.ids)], ["unit_amount"], []
)
group = groups[0] if groups else {}
wizard.sheet_count = len(sheets)
wizard.timesheet_count = group.get("__count") or 0
wizard.timesheet_hours = group.get("unit_amount") or 0.0
@api.depends("sheet_count", "timesheet_count", "timesheet_hours")
def _compute_summary_html(self):
for wizard in self:
wizard.summary_html = wizard._build_summary_html()
def _build_summary_html(self):
self.ensure_one()
if not self.sheet_count:
return Markup("<p>%s</p>") % _(
"There is no frozen timesheet sheet. Nothing to unfreeze."
)
return Markup("<p>%s</p>") % _(
"The timesheet sheets frozen by the review workflow, and the "
"timesheet entries they keep locked, are:"
)
def action_unfreeze(self):
self.ensure_one()
count = self.env["hr_timesheet.sheet"]._unfreeze_sheets(self.sheet_ids)
if count:
message = _("%s timesheet sheet(s) have been set back to draft.", count)
else:
message = _("No frozen timesheet sheet was found. Nothing was changed.")
return {
"type": "ir.actions.client",
"tag": "display_notification",
"params": {
"title": _("Unfreeze Pending Sheets"),
"message": message,
"type": "success" if count else "info",
"sticky": False,
"next": {"type": "ir.actions.act_window_close"},
},
}

View file

@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8" ?>
<!--
Copyright 2026 Criptomart
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
-->
<odoo>
<record id="hr_timesheet_sheet_unfreeze_wizard_form" model="ir.ui.view">
<field name="name">hr_timesheet.sheet.unfreeze.wizard.form</field>
<field name="model">hr_timesheet.sheet.unfreeze.wizard</field>
<field name="arch" type="xml">
<form string="Unfreeze Pending Sheets">
<!--
The figures depend on sheet_ids, and the onchange flow that
builds the dialog only asks default_get() for the fields of
the view. Drop this field and the dialog reports zero.
-->
<field name="sheet_ids" invisible="1" />
<field name="summary_html" nolabel="1" readonly="1" />
<group attrs="{'invisible': [('sheet_count', '=', 0)]}">
<field name="sheet_count" />
<field name="timesheet_count" />
<field name="timesheet_hours" widget="float_time" />
</group>
<div
class="alert alert-warning"
role="alert"
attrs="{'invisible': [('sheet_count', '=', 0)]}"
>
This is a mass operation on historical data and it cannot be
undone: the reviewer of every approved sheet is cleared. Make
sure you have a recent backup before you continue.
</div>
<footer>
<button
name="action_unfreeze"
string="Unfreeze"
type="object"
class="btn-primary"
attrs="{'invisible': [('sheet_count', '=', 0)]}"
/>
<button string="Cancel" class="btn-secondary" special="cancel" />
</footer>
</form>
</field>
</record>
<record
id="action_hr_timesheet_sheet_unfreeze_wizard"
model="ir.actions.act_window"
>
<field name="name">Unfreeze Pending Sheets</field>
<field name="res_model">hr_timesheet.sheet.unfreeze.wizard</field>
<field name="view_mode">form</field>
<field name="view_id" ref="hr_timesheet_sheet_unfreeze_wizard_form" />
<field name="target">new</field>
</record>
</odoo>

View file

@ -0,0 +1,19 @@
{
"name": "Mail Quoted Reply Icon",
"summary": "Changes the icon for quoted reply from fa-reply to fa-envelope",
"version": "16.0.1.0.0",
"category": "Discuss",
"license": "AGPL-3",
"author": "Criptoamrt",
"website": "https://criptomart.net",
"depends": ["mail_quoted_reply"],
"data": [],
"demo": [],
"installable": True,
"application": False,
"assets": {
"web.assets_backend": [
"/mail_quoted_reply_icon/static/src/models/*.js",
],
},
}

View file

@ -0,0 +1,33 @@
/** @odoo-module **/
import {registerPatch} from "@mail/model/model_core";
registerPatch({
name: "MessageActionView",
fields: {
classNames: {
compute() {
let classNames = this._super() || "";
if (
this.messageAction.messageActionListOwner ===
this.messageAction.replyMessageAction
) {
// Replace the fa-reply icon added by mail_quoted_reply with fa-envelope
classNames = classNames.replace("fa-reply", "fa-envelope");
}
return classNames;
},
},
title: {
compute() {
if (
this.messageAction.messageActionListOwner ===
this.messageAction.replyMessageAction
) {
return this.env._t("Reply by Email");
}
return this._super();
},
},
},
});

View file

@ -0,0 +1,24 @@
# Partner Email Sync Portal Login
When the email of a contact that has a **portal user** is changed and saved:
- the user login is updated with the new (normalized) email, and
- a password reset email is sent to the new address.
A note is posted in the contact chatter with the result.
## Behaviour details
- Only portal users are affected; internal users are never modified.
- Empty or invalid emails are ignored (login is kept).
- Changes that only differ in letter case do not trigger anything.
- If another user already uses the new email as login, the change is
blocked with an error message.
- If the reset email cannot be sent, the login is still updated and the
error is recorded in the chatter.
- From the portal access wizard ("Grant portal access" / "Invite again")
the login is synced but no reset email is sent, since the wizard sends
its own invitation.
- Use the context key `skip_portal_login_sync` to disable the behaviour
(e.g. in imports).
- Changing the login closes the user's open sessions.

View file

@ -0,0 +1,2 @@
from . import models
from . import wizard

View file

@ -0,0 +1,17 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
{
"name": "Partner Email Sync Portal Login",
"summary": "Update the portal user login and send a password reset "
"email when the partner email changes",
"version": "16.0.1.0.0",
"category": "Tools",
"website": "https://criptomart.net",
"author": "Criptomart",
"maintainers": ["criptomart"],
"license": "AGPL-3",
"depends": ["portal"],
"data": [],
"installable": True,
}

View file

@ -0,0 +1,67 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * partner_email_sync_portal_login
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 16.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-10-06 09:57+0000\n"
"PO-Revision-Date: 2026-10-06 09:57+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"Language: es\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: partner_email_sync_portal_login
#. odoo-python
#: code:addons/partner_email_sync_portal_login/models/res_partner.py:0
#, python-format
msgid ""
"Cannot change the email of %(partner)s: there is already a user with login "
"%(login)s."
msgstr ""
"No se puede cambiar el email de %(partner)s: ya existe un usuario con el login "
"%(login)s."
#. module: partner_email_sync_portal_login
#: model:ir.model,name:partner_email_sync_portal_login.model_res_partner
msgid "Contact"
msgstr "Contacto"
#. module: partner_email_sync_portal_login
#: model:ir.model,name:partner_email_sync_portal_login.model_portal_wizard_user
msgid "Portal User Config"
msgstr "Configuración de usuario de portal"
#. module: partner_email_sync_portal_login
#. odoo-python
#: code:addons/partner_email_sync_portal_login/models/res_partner.py:0
#, python-format
msgid ""
"Portal user login changed from %(old)s to %(new)s and a password reset email"
" has been sent."
msgstr ""
"Login del usuario de portal cambiado de %(old)s a %(new)s y enviado el correo "
"de restablecimiento de contraseña."
#. module: partner_email_sync_portal_login
#. odoo-python
#: code:addons/partner_email_sync_portal_login/models/res_partner.py:0
#, python-format
msgid ""
"Portal user login changed from %(old)s to %(new)s, but the password reset "
"email could not be sent: %(error)s"
msgstr ""
"Login del usuario de portal cambiado de %(old)s a %(new)s, pero no se ha podido "
"enviar el correo de restablecimiento de contraseña: %(error)s"
#. module: partner_email_sync_portal_login
#. odoo-python
#: code:addons/partner_email_sync_portal_login/models/res_partner.py:0
#, python-format
msgid "Portal user login changed from %(old)s to %(new)s."
msgstr "Login del usuario de portal cambiado de %(old)s a %(new)s."

View file

@ -0,0 +1,60 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * partner_email_sync_portal_login
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 16.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-10-06 09:57+0000\n"
"PO-Revision-Date: 2026-10-06 09:57+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: partner_email_sync_portal_login
#. odoo-python
#: code:addons/partner_email_sync_portal_login/models/res_partner.py:0
#, python-format
msgid ""
"Cannot change the email of %(partner)s: there is already a user with login "
"%(login)s."
msgstr ""
#. module: partner_email_sync_portal_login
#: model:ir.model,name:partner_email_sync_portal_login.model_res_partner
msgid "Contact"
msgstr ""
#. module: partner_email_sync_portal_login
#: model:ir.model,name:partner_email_sync_portal_login.model_portal_wizard_user
msgid "Portal User Config"
msgstr ""
#. module: partner_email_sync_portal_login
#. odoo-python
#: code:addons/partner_email_sync_portal_login/models/res_partner.py:0
#, python-format
msgid ""
"Portal user login changed from %(old)s to %(new)s and a password reset email"
" has been sent."
msgstr ""
#. module: partner_email_sync_portal_login
#. odoo-python
#: code:addons/partner_email_sync_portal_login/models/res_partner.py:0
#, python-format
msgid ""
"Portal user login changed from %(old)s to %(new)s, but the password reset "
"email could not be sent: %(error)s"
msgstr ""
#. module: partner_email_sync_portal_login
#. odoo-python
#: code:addons/partner_email_sync_portal_login/models/res_partner.py:0
#, python-format
msgid "Portal user login changed from %(old)s to %(new)s."
msgstr ""

View file

@ -0,0 +1 @@
from . import res_partner

View file

@ -0,0 +1,102 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
import logging
from markupsafe import Markup
from odoo import _, models
from odoo.exceptions import UserError
from odoo.tools import email_normalize
_logger = logging.getLogger(__name__)
class ResPartner(models.Model):
_inherit = "res.partner"
def write(self, vals):
res = super().write(vals)
if "email" in vals and not self.env.context.get("skip_portal_login_sync"):
self._sync_portal_user_login()
return res
def _get_portal_users_to_sync(self):
self.ensure_one()
group_portal = self.env.ref("base.group_portal")
return self.sudo().user_ids.filtered(lambda u: group_portal in u.groups_id)
def _sync_portal_user_login(self):
"""Set the partner email as login of its portal users and send them
a password reset email."""
Users = self.env["res.users"].sudo().with_context(active_test=False)
for partner in self:
users = partner._get_portal_users_to_sync()
if not users:
continue
new_login = email_normalize(partner.email)
if not new_login:
_logger.info(
"Portal login not synced for partner %s: invalid email %r",
partner.id,
partner.email,
)
continue
for user in users:
if email_normalize(user.login) == new_login:
continue
if Users.search_count(
[("login", "=", new_login), ("id", "!=", user.id)]
):
raise UserError(
_(
"Cannot change the email of %(partner)s: there is "
"already a user with login %(login)s.",
partner=partner.display_name,
login=new_login,
)
)
old_login = user.login
user.write({"login": new_login})
_logger.info(
"Portal user %s login changed from %s to %s",
user.id,
old_login,
new_login,
)
if self.env.context.get("portal_login_sync_no_mail"):
body = _(
"Portal user login changed from %(old)s to %(new)s.",
old=old_login,
new=new_login,
)
else:
body = partner._send_portal_login_reset_password(
user, old_login, new_login
)
partner.sudo().message_post(body=Markup("<p>%s</p>") % body)
def _send_portal_login_reset_password(self, user, old_login, new_login):
try:
with self.env.cr.savepoint():
user.action_reset_password()
except Exception as e:
# Do not block the email change if the reset email cannot be sent
_logger.warning(
"Password reset email for portal user %s could not be sent: %s",
user.id,
e,
)
return _(
"Portal user login changed from %(old)s to %(new)s, but the "
"password reset email could not be sent: %(error)s",
old=old_login,
new=new_login,
error=str(e),
)
return _(
"Portal user login changed from %(old)s to %(new)s and a password "
"reset email has been sent.",
old=old_login,
new=new_login,
)

View file

@ -0,0 +1 @@
from . import test_partner_email_sync_portal_login

View file

@ -0,0 +1,99 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from unittest.mock import patch
from odoo.exceptions import UserError
from odoo.tests import TransactionCase, tagged
from odoo.addons.auth_signup.models.res_users import ResUsers
RESET_PASSWORD = "action_reset_password"
@tagged("post_install", "-at_install")
class TestPartnerEmailSyncPortalLogin(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True))
Users = cls.env["res.users"].with_context(no_reset_password=True)
cls.portal_user = Users.create(
{
"name": "Portal Member",
"login": "member@example.com",
"email": "member@example.com",
"groups_id": [(6, 0, [cls.env.ref("base.group_portal").id])],
}
)
cls.internal_user = Users.create(
{
"name": "Internal Member",
"login": "internal@example.com",
"email": "internal@example.com",
"groups_id": [(6, 0, [cls.env.ref("base.group_user").id])],
}
)
def test_portal_user_login_synced_and_reset_sent(self):
with patch.object(ResUsers, RESET_PASSWORD, autospec=True) as reset:
self.portal_user.partner_id.email = "New.Member@Example.com"
self.assertEqual(self.portal_user.login, "new.member@example.com")
reset.assert_called_once()
self.assertEqual(reset.call_args.args[0], self.portal_user)
def test_internal_user_not_synced(self):
with patch.object(ResUsers, RESET_PASSWORD, autospec=True) as reset:
self.internal_user.partner_id.email = "other@example.com"
self.assertEqual(self.internal_user.login, "internal@example.com")
reset.assert_not_called()
def test_partner_without_user(self):
partner = self.env["res.partner"].create(
{"name": "No User", "email": "nouser@example.com"}
)
with patch.object(ResUsers, RESET_PASSWORD, autospec=True) as reset:
partner.email = "nouser2@example.com"
reset.assert_not_called()
def test_invalid_or_empty_email_keeps_login(self):
with patch.object(ResUsers, RESET_PASSWORD, autospec=True) as reset:
self.portal_user.partner_id.email = False
self.portal_user.partner_id.email = "not-an-email"
self.assertEqual(self.portal_user.login, "member@example.com")
reset.assert_not_called()
def test_same_email_different_case_not_resent(self):
with patch.object(ResUsers, RESET_PASSWORD, autospec=True) as reset:
self.portal_user.partner_id.email = "MEMBER@example.com"
self.assertEqual(self.portal_user.login, "member@example.com")
reset.assert_not_called()
def test_login_already_used(self):
with self.assertRaises(UserError):
self.portal_user.partner_id.email = "internal@example.com"
def test_reset_email_failure_does_not_block(self):
with patch.object(
ResUsers, RESET_PASSWORD, autospec=True, side_effect=UserError("boom")
):
self.portal_user.partner_id.email = "fail@example.com"
self.assertEqual(self.portal_user.login, "fail@example.com")
def test_skip_context(self):
partner = self.portal_user.partner_id.with_context(
skip_portal_login_sync=True
)
with patch.object(ResUsers, RESET_PASSWORD, autospec=True) as reset:
partner.email = "skip@example.com"
self.assertEqual(self.portal_user.login, "member@example.com")
reset.assert_not_called()
def test_no_mail_context_syncs_login_only(self):
partner = self.portal_user.partner_id.with_context(
portal_login_sync_no_mail=True
)
with patch.object(ResUsers, RESET_PASSWORD, autospec=True) as reset:
partner.email = "wizard@example.com"
self.assertEqual(self.portal_user.login, "wizard@example.com")
reset.assert_not_called()

View file

@ -0,0 +1 @@
from . import portal_wizard

View file

@ -0,0 +1,14 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import models
class PortalWizardUser(models.TransientModel):
_inherit = "portal.wizard.user"
def _update_partner_email(self):
# The portal wizard already sends its own invitation email
return super(
PortalWizardUser, self.with_context(portal_login_sync_no_mail=True)
)._update_partner_email()

View file

@ -0,0 +1,133 @@
===========================================
Pasarela de pago Redsys con preautorización
===========================================
..
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:1eb9535d052a619aa545a383869076ca1dc1fade22746cb3fe13afa726c3e3a3
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
:target: https://odoo-community.org/page/development-status
:alt: Beta
.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
:alt: License: AGPL-3
.. |badge3| image:: https://img.shields.io/badge/github-Criptomart%2Ffoodcoopbcn-lightgray.png?logo=github
:target: https://github.com/Criptomart/foodcoopbcn/tree/12.0/payment_redsys_preauth
:alt: Criptomart/foodcoopbcn
|badge1| |badge2| |badge3|
Este módulo añade la opción de pago a través de la pasarela de Redsys con preautorización con tarjeta bancaria.
**Table of contents**
.. contents::
:local:
Configuration
=============
Parámetros
~~~~~~~~~~
* **Nombre del comercio**: Indicaremos el nombre del comercio.
* **Número de comercio (FUC)**: Indicaremos el número de comercio que
nuestra entidad nos ha comunicado.
* **Clave secreta de encriptación**: Indicaremos la clave de encriptación
que tiene el comercio.
* **Número de terminal**: Indicaremos el terminal del TPV.
* **Tipo de firma**: Seleccionaremos el tipo de firma del comercio.
* **Tipo de moneda**: Seleccionaremos la moneda de nuestro terminal TPV
(Normalmente EUR - Euros).
* **Tipo de transacción**: Indicaremos el tipo de transacción, 1.
* **Idioma TPV**: Indicaremos el idioma en el TPV.
* **Método de pago**: Indicaremos que tipo de pago se debe aceptar, pago con
tarjeta, Bizum u otro de los disponibles.
* **URL_OK/URL_KO**: Durante el proceso del pago, y una vez que
se muestra al cliente la pantalla con el resultado del mismo, es
posible redirigir su navegador a una URL para las transacciones
autorizadas y a otra si la transacción ha sido denegada. A éstas
se las denomina URL_OK y URL_KO, respectivamente. Se trata
de dos URLs que pueden ser proporcionadas por el comercio.
* **Porcentaje de pago**: Indicar el porcentaje de pago que se permite, si
se deja a 0.0 se entiende 100%.
Nota
~~~~
Se tiene que verificar la configuración del comercio en el
módulo de administración de Redsys, donde la opción “Parámetros en las
URLs” debe tener el valor “SI”.
En caso de que exista más de una base de datos en la instalación, cuando la
pasarela de pago envía el formulario a "/payment/redsys/return" odoo no sabe
con que base de datos procesar esta información, por lo que hay que establecer
los parametros **dbfilter** y **dbname** en el archivo de configuración.
Para mostrar simultáneamente en el comercio electrónico varios de los métodos
de pago que proporciona Redsys, como pago con tarjeta y Bizum por ejemplo,
debemos duplicar el medio de pago y escoger en el campo método de pago el que
corresponda en cada caso.
En los pagos con preautorización, el titular del TPV deberá solicitar al banco su activación.
Known issues / Roadmap
======================
De acuerdo a los requisitos de Redsys, el tamaño máximo del número de pedido
resultante (``Ds_Order``) es de 12 caracteres. Este addon trunca dicho
dato en el comienzo de la transacción, con el resultado de que, aunque Redsys
la aceptará, no se finalizará el pedido pedido en Odoo al retornar y entregará
un Error 500 a Redsys.
Para evitar esto, es recomendable seleccionar secuencias de pedido que se
ajusten a esta limitación, en particular de 10 o menos caracteres, al ser la
referencia un número extendido a partir del pedido, que incluye un guion y un
número secuencial para posibles repeticiones de pago.
Bug Tracker
===========
Bugs are tracked on `GitHub Issues <https://github.com/Criptomart/foodcoopbcn/issues>`_.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us to smash it by providing a detailed and welcomed
`feedback <https://github.com/Criptomart/foodcoopbcn/issues/new?body=module:%20payment_redsys_preauth%0Aversion:%2012.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.
Do not contact contributors directly about support or help with technical issues.
Credits
=======
Authors
~~~~~~~
* Criptomart
Contributors
~~~~~~~~~~~~
* `Criptomart <https://www.criptomart.net>`_:
* Santi Noreña
Maintainers
~~~~~~~~~~~
This module is part of the `Criptomart/foodcoopbcn <https://github.com/Criptomart/foodcoopbcn/tree/12.0/payment_redsys_preauth>`_ project on GitHub.
You are welcome to contribute.

View file

@ -0,0 +1,4 @@
# License AGPL-3 - See https://www.gnu.org/licenses/agpl-3.0.html
from . import models
from . import controllers

View file

@ -0,0 +1,15 @@
# Copyright 2024 - 2025 Criptomart
{
"name": "Pasarela de pago Redsys con preautorización",
"category": "Payment Acquirer",
"summary": "Payment Acquirer: Redsys Implementation with Preauthorization",
"version": "16.0.0.0.1",
"author": "Criptomart",
"website": "https://git.criptomart.net/foodcoopbcn",
"depends": ["payment_redsys","sale_order_invoice_amount"],
"data": [
"data/payment_redsys.xml"
],
"license": "AGPL-3",
"installable": True,
}

View file

@ -0,0 +1,2 @@
from . import main

View file

@ -0,0 +1,29 @@
# © 2024 - 2025 criptomart.net
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging
import pprint
from odoo import http
from odoo.http import request
from odoo.addons.payment_redsys.controllers.main import RedsysController
_logger = logging.getLogger(__name__)
class RedsysController(RedsysController):
_return_url = '/payment/redsys/return'
_cancel_url = '/payment/redsys/cancel'
_exception_url = '/payment/redsys/error'
_reject_url = '/payment/redsys/reject'
@http.route(['/payment/redsys/result/<page>'], type='http', auth='public',methods=['GET'], website=True)
def redsys_result(self, page, **post):
_logger.info('________ Redsys return sync: %s -- %s',
pprint.pformat(post), page)
# double check
if post:
request.env['payment.transaction'].sudo()._handle_notification_data('redsys', post)
return request.redirect('/payment/status')

View file

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright 2024-2025 Criptomart
License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl-3). -->
<odoo noupdate="1">
<record id="payment_provider_redsys_preauth" model="payment.provider">
<field name="name">Redsys Preautorización </field>
<field name="image_128" type="base64" file="payment_redsys_preauth/static/description/icon.png"/>
<field name="code">redsys</field>
<field name="company_id" ref="base.main_company"/>
<field name="redsys_merchant_name">Bussines name</field>
<field name="redsys_merchant_code">8888888</field>
<field name="redsys_merchant_description">Bussines description</field>
<field name="redsys_secret_key">xxxxxxxxxxxx -- SHA256</field>
<field name="redsys_terminal">001</field>
<field name="redsys_transaction_type">1</field>
<field name="module_id" ref="base.module_payment_redsys_preauth"/>
<field name="pre_msg"><![CDATA[<p>You will be redirected to the Redsys payment gateway after clicking on the payment button.</p>]]></field>
</record>
<record id="payment_icon_cc_bizum" model="payment.icon">
<field name="name">Bizum</field>
<field name="image" type="base64" file="payment_redsys_preauth/static/img/bizum.png"/>
</record>
</odoo>

View file

@ -0,0 +1,2 @@
from . import payment_provider
from . import payment_transaction

View file

@ -0,0 +1,135 @@
# © 2024 - 2025 criptomart
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging
import json
import pprint
import requests
import urllib
from odoo import models, api, exceptions, fields
from odoo.addons.payment.models.payment_provider import ValidationError
from odoo.tools.float_utils import float_compare
from odoo.tools.translate import _
import odoo.exceptions
_logger = logging.getLogger(__name__)
class PaymentProvider(models.Model):
_inherit = 'payment.provider'
@api.depends('code')
def _compute_feature_support_fields(self):
super()._compute_feature_support_fields()
self.update({
'support_manual_capture': 'partial',
})
def get_redsys_rest_urls(self, environment):
""" Redsys Rest URLs
"""
if environment == 'prod':
return {
'redsys_rest_url':
'https://sis.redsys.es/sis/rest/trataPeticionREST',
}
else:
# redsys no acepta preautorizaciones en test, pero puede que cambie...
return {
'redsys_rest_url':
'https://sis-t.redsys.es:25443/sis/rest/trataPeticionREST',
}
def _prepare_merchant_parameters(self, tx_values):
base_url = self._get_website_url()
callback_url = "http://botiga.foodcoopbcn.cat"
values = {
"Ds_Sermepa_Url": self._redsys_get_api_url(),
"Ds_Merchant_Amount": str(int(round(tx_values["amount"] * 100))),
"Ds_Merchant_Currency": self.redsys_currency or "978",
"Ds_Merchant_Order": (
tx_values["reference"] and tx_values["reference"][-12:] or False
),
"Ds_Merchant_MerchantCode": (
self.redsys_merchant_code and self.redsys_merchant_code[:9]
),
"Ds_Merchant_Terminal": self.redsys_terminal or "1",
"Ds_Merchant_TransactionType": (self.redsys_transaction_type or "1"),
"Ds_Merchant_Titular": tx_values.get(
"billing_partner", self.env.user.partner_id
).display_name[:60],
"Ds_Merchant_MerchantName": (
self.redsys_merchant_name and self.redsys_merchant_name[:25]
),
"Ds_Merchant_MerchantUrl": (
"%s/payment/redsys/return" % (callback_url or base_url)
)[:250],
"Ds_Merchant_MerchantData": self.redsys_merchant_data or "",
"Ds_Merchant_ProductDescription": (
self.redsys_merchant_description
and self.redsys_merchant_description[:125]
),
"Ds_Merchant_ConsumerLanguage": (self.redsys_merchant_lang or "001"),
"Ds_Merchant_UrlOk": "%s/payment/redsys/result/redsys_result_ok" % base_url,
"Ds_Merchant_UrlKo": "%s/payment/redsys/result/redsys_result_ko" % base_url,
"Ds_Merchant_Paymethods": self.redsys_pay_method or "T",
}
return self._url_encode64(json.dumps(values)).decode("utf-8")
def _prepare_s2s_merchant_parameters(self, tx_values):
values = {
'Ds_Merchant_Amount': str(int(round(tx_values['amount'] * 100))),
'Ds_Merchant_Currency': self.redsys_currency or '978',
'Ds_Merchant_Order': (
tx_values['reference'] and tx_values['reference'][-12:] or
False),
'Ds_Merchant_MerchantCode': (
self.redsys_merchant_code and
self.redsys_merchant_code[:9]),
'Ds_Merchant_Terminal': self.redsys_terminal or '1',
}
if 'transaction_type' in tx_values:
values.update({'Ds_Merchant_TransactionType': tx_values['transaction_type']})
else:
values.update({'Ds_Merchant_TransactionType':
(self.redsys_transaction_type or '2')}),
return self._url_encode64(json.dumps(values))
def redsys_s2s_create_transaction(self, values):
self.ensure_one()
redsys_values = dict()
merchant_parameters = self._prepare_s2s_merchant_parameters(values)
redsys_values.update({
'Ds_SignatureVersion': str(self.redsys_signature_version),
'Ds_MerchantParameters': merchant_parameters,
'Ds_Signature': self.sign_parameters(
self.redsys_secret_key, merchant_parameters),
})
return redsys_values
def redsys_send_s2s_msg(self, values):
toSend = self.redsys_s2s_create_transaction(values)
url = self.get_redsys_rest_urls('prod')['redsys_rest_url']
_logger.info('Redsys: sending rest values:\n%s', pprint.pformat(toSend))
try:
if toSend and url:
response = requests.post(url, data=toSend)
else:
raise exceptions.ValidationError('Error en los parámetros de la petición rest')
except requests.exceptions.RequestException as e:
_logger.error('Redsys: Error sending rest transaction %s:\n%s', values['reference'], str(e))
return False
if response and response.status_code != 200:
_logger.error('Redsys: Error sending rest transaction %s: %s', values['reference'], response.status_code)
return False
_logger.info('Redsys: rest response:\n%s', pprint.pformat(response.text))
try:
errorCheck = response.json()
if errorCheck.get('errorCode', ''):
_logger.error('Redsys: Error recibido tx %s: %s', values['reference'], errorCheck.get('errorCode'))
except json.JSONDecodeError:
_logger.error('Error al decodificar la respuesta JSON para la transacción %s', values['reference'])
return False
return errorCheck

View file

@ -0,0 +1,150 @@
# © 2024 - 2025 criptomart
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging
import json
import pprint
import requests
from odoo import models, api, exceptions, fields
from odoo.addons.payment.models.payment_provider import ValidationError
from odoo.tools.float_utils import float_compare
from odoo.tools.translate import _
_logger = logging.getLogger(__name__)
class TxRedsysPreAuth(models.Model):
_inherit = 'payment.transaction'
def _get_specific_rendering_values(self, processing_values):
if self.provider_code != "redsys":
return super()._get_specific_rendering_values(processing_values)
redsys_values = dict(processing_values)
merchant_parameters = self.provider_id._prepare_merchant_parameters(
processing_values
)
redsys_values.update(
{
"api_url": self.provider_id._redsys_get_api_url(),
"Ds_SignatureVersion": str(self.provider_id.redsys_signature_version),
"Ds_MerchantParameters": merchant_parameters,
"Ds_Signature": self.provider_id.sign_parameters(
self.provider_id.redsys_secret_key, merchant_parameters
),
}
)
return redsys_values
# chequea que el usuario cancele el pago 9915. cambia el stado de done a authorized
@api.model
def _get_redsys_state(self, status_code):
if 0 <= status_code <= 100:
return "authorized"
elif status_code <= 203:
return "pending"
elif status_code == 400:
return "cancel"
elif status_code == 900:
return "done"
elif 912 <= status_code <= 9915:
return "cancel"
else:
return "error"
def _process_notification_data(self, data):
if self.provider_code != "redsys":
return super()._process_notification_data(data)
params = self.merchant_params_json2dict(data)
status_code = int(params.get('Ds_Response', '29999'))
state = self._get_redsys_state(status_code)
vals = {
'state': state,
'redsys_txnid': params.get('Ds_AuthorisationCode'),
}
state_message = ""
feedback_error = False
if state == 'done':
vals['state_message'] = _('Ok: %s') % params.get('Ds_Response')
self._set_done()
self._finalize_post_processing()
if state == 'authorized':
vals['state_message'] = _('Auth Ok: %s') % params.get('Ds_Response')
self._set_authorized()
self._finalize_post_processing()
elif state == 'pending': # 'Payment error: code: %s.'
state_message = _("Redsys: feedback error: %s" %(status_code))
self._set_pending()
elif state == 'cancel': # 'Payment error: bank unavailable.'
state_message = _("Redsys: feedback error: %s" %(status_code))
self._set_canceled()
else:
state_message = _("Redsys: feedback error: %s" %(status_code))
self._set_error(state_message)
feedback_error = True
if state_message:
vals['state_message'] = state_message % {
"status_code": params.get("Ds_Response"),
"error_code": params.get("Ds_ErrorCode"),
}
if state == 'error':
_logger.warning(vals['state_message'])
self.write(vals)
def _send_capture_request(self, amount_to_capture=None):
self.ensure_one()
self._ensure_provider_is_not_disabled()
_logger.info('Redsys: Capturing transaction %s -- %s', self.reference, amount_to_capture)
try:
if amount_to_capture:
values = {'amount': amount_to_capture}
else:
values = {'amount': sum(order.invoiced_amount for order in self.sale_order_ids)}
values.update({'reference': self.reference})
values.update({'transaction_type': '2'})
response = self.provider_id.redsys_send_s2s_msg(values)
if not response:
raise exceptions.ValidationError('Error mandando mensaje rest');
if response.get('errorCode', ''):
raise exceptions.ValidationError('Ref %s: Error Code %s' %(self.reference, response.get('errorCode')))
params = self.merchant_params_json2dict(response)
_logger.info('Redsys: parameters received:\n%s', pprint.pformat(params))
if params['Ds_Response'] == '0900' and params['Ds_Order'] == self.reference:
self.provider_reference = params['Ds_AuthorisationCode']
self._set_done()
_logger.info('Confirmacion de preautorizacion realizada con exito para la transaccion %s', self.reference)
message = ('Preautorizacion confirmada')
self.sale_order_ids.message_post(body=message)
else:
raise exceptions.ValidationError('Error en confirmacion de preautorizacion tx: %s - %s', self.reference, params['Ds_Response'])
except Exception as e:
_logger.exception('Redsys: Error capturando preautorización transacción %s:\n%s', self.reference, str(e))
raise exceptions.UserError(_('Error capturing preauthorization for transaction %s:\n%s') % (self.reference, str(e)))
def _send_void_request(self, amount_to_void=None):
self.ensure_one()
self._ensure_provider_is_not_disabled()
_logger.info('Redsys: Preauthorization cancel for transaction %s', self.reference)
try:
if amount_to_void:
values = {'amount': amount_to_void}
else:
values = {'amount': self.amount}
values.update({'reference': self.reference})
values.update({'transaction_type': '9'})
response = self.provider_id.redsys_send_s2s_msg(values)
if not response:
raise exceptions.ValidationError(_('error redsys_s2s_msg'))
if response.get('errorCode', ''):
raise exceptions.ValidationError('Ref %s: Error Code %s' % (self.reference, response.get('errorCode')))
params = self.merchant_params_json2dict(response)
_logger.info('Redsys: parameters received):\n%s', pprint.pformat(params))
if params['Ds_Response'] == '0400' and params['Ds_Order'] == self.reference:
self._set_canceled()
_logger.info('Preautorización cancelada con éxito para la transacción %s', self.reference)
else:
raise exceptions.ValidationError('No se pudo cancelar la preautorización para la transacción %s - %s', self.reference, params['Ds_Response'])
except Exception as e:
_logger.exception('Redsys Error cancelando preautorización tx %s:\n%s', self.reference, str(e))
raise exceptions.UserError(_('Error cancelling preauthorization for transaction %s:\n%s') % (self.reference, str(e)))

View file

@ -0,0 +1,54 @@
Parámetros
~~~~~~~~~~
* **Nombre del comercio**: Indicaremos el nombre del comercio.
* **Número de comercio (FUC)**: Indicaremos el número de comercio que
nuestra entidad nos ha comunicado.
* **Clave secreta de encriptación**: Indicaremos la clave de encriptación
que tiene el comercio.
* **Número de terminal**: Indicaremos el terminal del TPV.
* **Tipo de firma**: Seleccionaremos el tipo de firma del comercio.
* **Tipo de moneda**: Seleccionaremos la moneda de nuestro terminal TPV
(Normalmente EUR - Euros).
* **Tipo de transacción**: Indicaremos el tipo de transacción, 1.
* **Idioma TPV**: Indicaremos el idioma en el TPV.
* **Método de pago**: Indicaremos que tipo de pago se debe aceptar, pago con
tarjeta, Bizum u otro de los disponibles.
* **URL_OK/URL_KO**: Durante el proceso del pago, y una vez que
se muestra al cliente la pantalla con el resultado del mismo, es
posible redirigir su navegador a una URL para las transacciones
autorizadas y a otra si la transacción ha sido denegada. A éstas
se las denomina URL_OK y URL_KO, respectivamente. Se trata
de dos URLs que pueden ser proporcionadas por el comercio.
* **Porcentaje de pago**: Indicar el porcentaje de pago que se permite, si
se deja a 0.0 se entiende 100%.
Nota
~~~~
Se tiene que verificar la configuración del comercio en el
módulo de administración de Redsys, donde la opción “Parámetros en las
URLs” debe tener el valor “SI”.
En caso de que exista más de una base de datos en la instalación, cuando la
pasarela de pago envía el formulario a "/payment/redsys/return" odoo no sabe
con que base de datos procesar esta información, por lo que hay que establecer
los parametros **dbfilter** y **dbname** en el archivo de configuración.
Para mostrar simultáneamente en el comercio electrónico varios de los métodos
de pago que proporciona Redsys, como pago con tarjeta y Bizum por ejemplo,
debemos duplicar el medio de pago y escoger en el campo método de pago el que
corresponda en cada caso.
En los pagos con preautorización, el titular del TPV deberá solicitar al banco su activación.

View file

@ -0,0 +1,3 @@
* `Criptomart <https://www.criptomart.net>`_:
* Santi Noreña

View file

@ -0,0 +1 @@
Este módulo añade la opción de pago a través de la pasarela de Redsys con preautorización con tarjeta bancaria.

View file

@ -0,0 +1,9 @@
De acuerdo a los requisitos de Redsys, el tamaño máximo del número de pedido
resultante (``Ds_Order``) es de 12 caracteres. Este addon trunca dicho
dato en el comienzo de la transacción, con el resultado de que, aunque Redsys
la aceptará, no se finalizará el pedido pedido en Odoo al retornar y entregará
un Error 500 a Redsys.
Para evitar esto, es recomendable seleccionar secuencias de pedido que se
ajusten a esta limitación, en particular de 10 o menos caracteres, al ser la
referencia un número extendido a partir del pedido, que incluye un guion y un
número secuencial para posibles repeticiones de pago.

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View file

@ -1,10 +1,9 @@
<?xml version="1.0" encoding="utf-8" ?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en"> <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head> <head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="generator" content="Docutils 0.15.1: http://docutils.sourceforge.net/" /> <meta name="generator" content="Docutils: http://docutils.sourceforge.net/" />
<title>Require Product Quantity in POS</title> <title>Pasarela de pago Redsys con preautorización</title>
<style type="text/css"> <style type="text/css">
/* /*
@ -360,63 +359,120 @@ ul.auto-toc {
</style> </style>
</head> </head>
<body> <body>
<div class="document" id="require-product-quantity-in-pos"> <div class="document" id="pasarela-de-pago-redsys-con-preautorizacion">
<h1 class="title">Require Product Quantity in POS</h1> <h1 class="title">Pasarela de pago Redsys con preautorización</h1>
<!-- !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! <!-- !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !! !! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !! !! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:1eb9535d052a619aa545a383869076ca1dc1fade22746cb3fe13afa726c3e3a3
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! --> !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
<p><a class="reference external" href="https://odoo-community.org/page/development-status"><img alt="Beta" src="https://img.shields.io/badge/maturity-Beta-yellow.png" /></a> <a class="reference external" href="http://www.gnu.org/licenses/agpl-3.0-standalone.html"><img alt="License: AGPL-3" src="https://img.shields.io/badge/licence-AGPL--3-blue.png" /></a> <a class="reference external" href="https://github.com/OCA/pos/tree/14.0/pos_require_product_quantity"><img alt="OCA/pos" src="https://img.shields.io/badge/github-OCA%2Fpos-lightgray.png?logo=github" /></a> <a class="reference external" href="https://translation.odoo-community.org/projects/pos-14-0/pos-14-0-pos_require_product_quantity"><img alt="Translate me on Weblate" src="https://img.shields.io/badge/weblate-Translate%20me-F47D42.png" /></a> <a class="reference external" href="https://runbot.odoo-community.org/runbot/184/14.0"><img alt="Try me on Runbot" src="https://img.shields.io/badge/runbot-Try%20me-875A7B.png" /></a></p> <p><a class="reference external" href="https://odoo-community.org/page/development-status"><img alt="Beta" src="https://img.shields.io/badge/maturity-Beta-yellow.png" /></a> <a class="reference external" href="http://www.gnu.org/licenses/agpl-3.0-standalone.html"><img alt="License: AGPL-3" src="https://img.shields.io/badge/licence-AGPL--3-blue.png" /></a> <a class="reference external" href="https://github.com/Criptomart/foodcoopbcn/tree/12.0/payment_redsys_preauth"><img alt="Criptomart/foodcoopbcn" src="https://img.shields.io/badge/github-Criptomart%2Ffoodcoopbcn-lightgray.png?logo=github" /></a></p>
<p>This module forbids adding empty order lines on POS orders.</p> <p>Este módulo añade la opción de pago a través de la pasarela de Redsys con preautorización con tarjeta bancaria.</p>
<p>When clicking on the “Payment” button in the Point of Sale,
a popup is shown if product quantity for one or more order
lines is set to 0.</p>
<p><strong>Table of contents</strong></p> <p><strong>Table of contents</strong></p>
<div class="contents local topic" id="contents"> <div class="contents local topic" id="contents">
<ul class="simple"> <ul class="simple">
<li><a class="reference internal" href="#bug-tracker" id="id1">Bug Tracker</a></li> <li><a class="reference internal" href="#configuration" id="id1">Configuration</a><ul>
<li><a class="reference internal" href="#credits" id="id2">Credits</a><ul> <li><a class="reference internal" href="#parametros" id="id2">Parámetros</a></li>
<li><a class="reference internal" href="#authors" id="id3">Authors</a></li> <li><a class="reference internal" href="#nota" id="id3">Nota</a></li>
<li><a class="reference internal" href="#contributors" id="id4">Contributors</a></li> </ul>
<li><a class="reference internal" href="#maintainers" id="id5">Maintainers</a></li> </li>
<li><a class="reference internal" href="#known-issues-roadmap" id="id4">Known issues / Roadmap</a></li>
<li><a class="reference internal" href="#bug-tracker" id="id5">Bug Tracker</a></li>
<li><a class="reference internal" href="#credits" id="id6">Credits</a><ul>
<li><a class="reference internal" href="#authors" id="id7">Authors</a></li>
<li><a class="reference internal" href="#contributors" id="id8">Contributors</a></li>
<li><a class="reference internal" href="#maintainers" id="id9">Maintainers</a></li>
</ul> </ul>
</li> </li>
</ul> </ul>
</div> </div>
<div class="section" id="configuration">
<h1><a class="toc-backref" href="#id1">Configuration</a></h1>
<div class="section" id="parametros">
<h2><a class="toc-backref" href="#id2">Parámetros</a></h2>
<ul class="simple">
<li><strong>Nombre del comercio</strong>: Indicaremos el nombre del comercio.</li>
<li><strong>Número de comercio (FUC)</strong>: Indicaremos el número de comercio que
nuestra entidad nos ha comunicado.</li>
<li><strong>Clave secreta de encriptación</strong>: Indicaremos la clave de encriptación
que tiene el comercio.</li>
<li><strong>Número de terminal</strong>: Indicaremos el terminal del TPV.</li>
<li><strong>Tipo de firma</strong>: Seleccionaremos el tipo de firma del comercio.</li>
<li><strong>Tipo de moneda</strong>: Seleccionaremos la moneda de nuestro terminal TPV
(Normalmente EUR - Euros).</li>
<li><strong>Tipo de transacción</strong>: Indicaremos el tipo de transacción, 1.</li>
<li><strong>Idioma TPV</strong>: Indicaremos el idioma en el TPV.</li>
<li><strong>Método de pago</strong>: Indicaremos que tipo de pago se debe aceptar, pago con
tarjeta, Bizum u otro de los disponibles.</li>
<li><strong>URL_OK/URL_KO</strong>: Durante el proceso del pago, y una vez que
se muestra al cliente la pantalla con el resultado del mismo, es
posible redirigir su navegador a una URL para las transacciones
autorizadas y a otra si la transacción ha sido denegada. A éstas
se las denomina URL_OK y URL_KO, respectivamente. Se trata
de dos URLs que pueden ser proporcionadas por el comercio.</li>
<li><strong>Porcentaje de pago</strong>: Indicar el porcentaje de pago que se permite, si
se deja a 0.0 se entiende 100%.</li>
</ul>
</div>
<div class="section" id="nota">
<h2><a class="toc-backref" href="#id3">Nota</a></h2>
<p>Se tiene que verificar la configuración del comercio en el
módulo de administración de Redsys, donde la opción “Parámetros en las
URLs” debe tener el valor “SI”.</p>
<p>En caso de que exista más de una base de datos en la instalación, cuando la
pasarela de pago envía el formulario a “/payment/redsys/return” odoo no sabe
con que base de datos procesar esta información, por lo que hay que establecer
los parametros <strong>dbfilter</strong> y <strong>dbname</strong> en el archivo de configuración.</p>
<p>Para mostrar simultáneamente en el comercio electrónico varios de los métodos
de pago que proporciona Redsys, como pago con tarjeta y Bizum por ejemplo,
debemos duplicar el medio de pago y escoger en el campo método de pago el que
corresponda en cada caso.</p>
<p>En los pagos con preautorización, el titular del TPV deberá solicitar al banco su activación.</p>
</div>
</div>
<div class="section" id="known-issues-roadmap">
<h1><a class="toc-backref" href="#id4">Known issues / Roadmap</a></h1>
<p>De acuerdo a los requisitos de Redsys, el tamaño máximo del número de pedido
resultante (<tt class="docutils literal">Ds_Order</tt>) es de 12 caracteres. Este addon trunca dicho
dato en el comienzo de la transacción, con el resultado de que, aunque Redsys
la aceptará, no se finalizará el pedido pedido en Odoo al retornar y entregará
un Error 500 a Redsys.
Para evitar esto, es recomendable seleccionar secuencias de pedido que se
ajusten a esta limitación, en particular de 10 o menos caracteres, al ser la
referencia un número extendido a partir del pedido, que incluye un guion y un
número secuencial para posibles repeticiones de pago.</p>
</div>
<div class="section" id="bug-tracker"> <div class="section" id="bug-tracker">
<h1><a class="toc-backref" href="#id1">Bug Tracker</a></h1> <h1><a class="toc-backref" href="#id5">Bug Tracker</a></h1>
<p>Bugs are tracked on <a class="reference external" href="https://github.com/OCA/pos/issues">GitHub Issues</a>. <p>Bugs are tracked on <a class="reference external" href="https://github.com/Criptomart/foodcoopbcn/issues">GitHub Issues</a>.
In case of trouble, please check there if your issue has already been reported. In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us smashing it by providing a detailed and welcomed If you spotted it first, help us to smash it by providing a detailed and welcomed
<a class="reference external" href="https://github.com/OCA/pos/issues/new?body=module:%20pos_require_product_quantity%0Aversion:%2014.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**">feedback</a>.</p> <a class="reference external" href="https://github.com/Criptomart/foodcoopbcn/issues/new?body=module:%20payment_redsys_preauth%0Aversion:%2012.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**">feedback</a>.</p>
<p>Do not contact contributors directly about support or help with technical issues.</p> <p>Do not contact contributors directly about support or help with technical issues.</p>
</div> </div>
<div class="section" id="credits"> <div class="section" id="credits">
<h1><a class="toc-backref" href="#id2">Credits</a></h1> <h1><a class="toc-backref" href="#id6">Credits</a></h1>
<div class="section" id="authors"> <div class="section" id="authors">
<h2><a class="toc-backref" href="#id3">Authors</a></h2> <h2><a class="toc-backref" href="#id7">Authors</a></h2>
<ul class="simple"> <ul class="simple">
<li>Coop IT Easy SCRLfs</li> <li>Criptomart</li>
</ul> </ul>
</div> </div>
<div class="section" id="contributors"> <div class="section" id="contributors">
<h2><a class="toc-backref" href="#id4">Contributors</a></h2> <h2><a class="toc-backref" href="#id8">Contributors</a></h2>
<ul class="simple"> <ul class="simple">
<li>Robin Keunen &lt;<a class="reference external" href="mailto:robin.keunen&#64;coopiteasy.be">robin.keunen&#64;coopiteasy.be</a>&gt;</li> <li><a class="reference external" href="https://www.criptomart.net">Criptomart</a>:<ul>
<li>Iván Todorovich &lt;<a class="reference external" href="mailto:ivan.todorovich&#64;gmail.com">ivan.todorovich&#64;gmail.com</a>&gt;</li> <li>Santi Noreña</li>
<li>Foram Shah &lt;<a class="reference external" href="mailto:foram.shah&#64;initos.com">foram.shah&#64;initos.com</a>&gt;</li> </ul>
</li>
</ul> </ul>
</div> </div>
<div class="section" id="maintainers"> <div class="section" id="maintainers">
<h2><a class="toc-backref" href="#id5">Maintainers</a></h2> <h2><a class="toc-backref" href="#id9">Maintainers</a></h2>
<p>This module is maintained by the OCA.</p> <p>This module is part of the <a class="reference external" href="https://github.com/Criptomart/foodcoopbcn/tree/12.0/payment_redsys_preauth">Criptomart/foodcoopbcn</a> project on GitHub.</p>
<a class="reference external image-reference" href="https://odoo-community.org"><img alt="Odoo Community Association" src="https://odoo-community.org/logo.png" /></a> <p>You are welcome to contribute.</p>
<p>OCA, or the Odoo Community Association, is a nonprofit organization whose
mission is to support the collaborative development of Odoo features and
promote its widespread use.</p>
<p>This module is part of the <a class="reference external" href="https://github.com/OCA/pos/tree/14.0/pos_require_product_quantity">OCA/pos</a> project on GitHub.</p>
<p>You are welcome to contribute. To learn how please visit <a class="reference external" href="https://odoo-community.org/page/Contribute">https://odoo-community.org/page/Contribute</a>.</p>
</div> </div>
</div> </div>
</div> </div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View file

@ -0,0 +1,3 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from . import models

View file

@ -0,0 +1,23 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
{
"name": "POS CashDro Allow Manual Amount",
"summary": "Permite introducir manualmente el importe en pagos CashDro",
"version": "16.0.1.0.0",
"category": "Point of Sale",
"license": "AGPL-3",
"author": "Criptomart",
"depends": [
"pos_payment_method_cashdro",
],
"data": [
"views/pos_config_views.xml",
],
"assets": {
"point_of_sale.assets": [
"pos_cashdro_allow_manual/static/src/js/PaymentScreen.esm.js",
"pos_cashdro_allow_manual/static/src/xml/PaymentScreenPaymentLines.xml",
],
},
"installable": True,
}

View file

@ -0,0 +1,4 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from . import pos_config
from . import res_config_settings

View file

@ -0,0 +1,18 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import fields, models
class PosConfig(models.Model):
_inherit = "pos.config"
cashdro_allow_manual_amount = fields.Boolean(
string="Permitir importe manual CashDro",
help=(
"Cuando está activado, aparece un botón 'Manual' en la pantalla de "
"pago del TPV que permite confirmar el importe de la línea sin pasar "
"por la máquina CashDro. Útil ante fallos de comunicación, "
"devoluciones u otras situaciones de emergencia."
),
default=True,
)

View file

@ -0,0 +1,12 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import fields, models
class ResConfigSettings(models.TransientModel):
_inherit = "res.config.settings"
pos_cashdro_allow_manual_amount = fields.Boolean(
related="pos_config_id.cashdro_allow_manual_amount",
readonly=False,
)

View file

@ -0,0 +1,45 @@
/** @odoo-module **/
// Copyright 2026 Criptomart
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
import {Component} from "point_of_sale.Registries";
import PaymentScreen from "point_of_sale.PaymentScreen";
import {useListener} from "@web/core/utils/hooks";
const CashdroAllowManualPaymentScreen = (OriginalPaymentScreen) =>
class extends OriginalPaymentScreen {
setup() {
super.setup();
useListener("cashdro-send-manual", this._cashdroSendManual);
}
/**
* Mark the Cashdro payment line as done using the amount that is already
* set on the line (entered manually via the numpad), without contacting
* the CashDro machine.
*
* Sets `cashdro_manual_done = true` on the line so the delete button
* remains visible even in `done` state, letting the cashier undo the
* operation in case of a mistake.
*
* Refuses to proceed when the amount is 0 to avoid locking the POS with
* an undeletable zero-amount payment line.
*/
async _cashdroSendManual({detail: line}) {
if (!line.get_amount()) {
this.showPopup("ErrorPopup", {
title: this.env._t("Importe no válido"),
body: this.env._t(
"Introduzca el importe en el teclado numérico antes de confirmar manualmente el pago."
),
});
return;
}
// Flag this line as manually confirmed so the template keeps
// showing its delete button even after the status is 'done'.
line.cashdro_manual_done = true;
line.set_payment_status("done");
}
};
Component.extend(PaymentScreen, CashdroAllowManualPaymentScreen);

View file

@ -0,0 +1,64 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!-- Copyright 2026 Criptomart
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
Extends the PaymentScreenPaymentLines template to add a "Manual" button
next to the Send/Retry action buttons on Cashdro payment lines.
The button is shown when:
- the payment method is of type "cashdro"
- cashdro_allow_manual_amount is True on the pos.config
- the line is in "pending" or "retry" state (the two states where
the numpad is already active and the cashier can edit the amount)
Clicking it triggers the "cashdro-send-manual" event which is handled
by CashdroAllowManualPaymentScreen in PaymentScreen.esm.js.
-->
<templates id="template" xml:space="preserve">
<t
t-name="pos_cashdro_allow_manual.PaymentScreenPaymentLines"
t-inherit="point_of_sale.PaymentScreenPaymentLines"
t-inherit-mode="extension"
owl="1"
>
<!--
//div[hasclass('send_payment_request')] matches two nodes:
1. the "Send" button shown in the "pending" state
2. the "Retry" button shown in the "retry" state
position="after" inserts our node after each match,
so the Manual button appears in both states.
-->
<xpath expr="//div[hasclass('send_payment_request')]" position="before">
<t
t-if="line.payment_method.use_payment_terminal === 'cashdro'
and env.pos.config.cashdro_allow_manual_amount"
>
<div
class="button cashdro-manual-button"
style="flex: 0 0 auto; padding: 0 12px; font-size: 0.8em; opacity: 0.75;"
title="Confirmar importe manualmente sin usar la máquina CashDro"
t-on-click="() => this.trigger('cashdro-send-manual', line)"
>
Manual
</div>
</t>
</xpath>
<!--
Extend the t-if on the wrapper <t> that controls the delete button
visibility so that manually-confirmed Cashdro lines keep showing the
delete button even in 'done' state. This lets the cashier undo
an accidental Manual press without blocking the POS session.
The targeted element is the <t t-if="..."> that wraps the
delete-button <div> inside the selected payment line block.
-->
<xpath
expr="//t[@t-if='line.selected']/div/t[div[hasclass('delete-button')]]"
position="attributes"
>
<attribute name="t-if">!line.payment_status or !['done', 'reversed', 'waitingCard', 'waitingCapture'].includes(line.payment_status) or line.cashdro_manual_done</attribute>
</xpath>
</t>
</templates>

View file

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!-- Copyright 2026 Criptomart
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -->
<odoo>
<record id="res_config_settings_cashdro_allow_manual_form" model="ir.ui.view">
<field name="name">res.config.settings.cashdro.allow.manual.form</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="point_of_sale.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//div[@id='payment_methods_new']" position="after">
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="pos_cashdro_allow_manual_amount"/>
</div>
<div class="o_setting_right_pane">
<label for="pos_cashdro_allow_manual_amount"
string="Permitir importe manual CashDro"/>
<div class="text-muted">
Muestra un botón "Manual" en la pantalla de pago para
confirmar el importe CashDro sin pasar por la máquina.
Útil ante fallos de comunicación o situaciones de emergencia.
</div>
</div>
</div>
</xpath>
</field>
</record>
</odoo>

View file

@ -0,0 +1,2 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).

View file

@ -0,0 +1,21 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
{
"name": "POS CashDro Refund",
"summary": "Permite devoluciones (payouts) a través de la máquina CashDro desde el TPV",
"version": "16.0.1.0.0",
"category": "Point of Sale",
"license": "AGPL-3",
"author": "Criptomart",
"depends": [
"pos_payment_method_cashdro",
],
"assets": {
"point_of_sale.assets": [
"pos_cashdro_refund/static/src/js/PasswordInputPopup.js",
"pos_cashdro_refund/static/src/js/payment_cashdro_refund.esm.js",
"pos_cashdro_refund/static/src/xml/PasswordInputPopup.xml",
],
},
"installable": True,
}

View file

@ -0,0 +1,38 @@
odoo.define("pos_cashdro_refund.PasswordInputPopup", function (require) {
"use strict";
/* Copyright 2026 Criptomart - extracted from pos_payment_method_cashdro
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). */
const AbstractAwaitablePopup = require("point_of_sale.AbstractAwaitablePopup");
const Registries = require("point_of_sale.Registries");
const {_lt} = require("@web/core/l10n/translation");
const {onMounted, useRef, useState} = owl;
class PasswordInputPopup extends AbstractAwaitablePopup {
setup() {
super.setup();
this.state = useState({inputValue: this.props.startingValue});
this.inputRef = useRef("input");
onMounted(this.onMounted);
}
onMounted() {
this.inputRef.el.focus();
}
getPayload() {
return this.state.inputValue;
}
}
PasswordInputPopup.template = "PasswordInputPopup";
PasswordInputPopup.defaultProps = {
confirmText: _lt("Ok"),
cancelText: _lt("Cancel"),
title: "",
body: "",
startingValue: "",
placeholder: "",
};
Registries.Component.add(PasswordInputPopup);
return PasswordInputPopup;
});

View file

@ -0,0 +1,120 @@
/** @odoo-module */
/* Copyright 2026 Criptomart - extracted from pos_payment_method_cashdro
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
Adds refund (payout) support to the CashDro payment terminal integration.
When the order due amount is negative (i.e. a return/refund), this module:
1. Asks the cashier for the CashDro password to authorise the payout.
2. Sends a type=3 (payout) request to the CashDro instead of the normal
type=4 (collection) request.
3. Sets the payment line amount to the negative amount dispensed by the
machine (totalout).
Normal (positive) payments are delegated unchanged to the original
cashdro_send_payment_request method.
*/
import {PaymentCashdro} from "@pos_payment_method_cashdro/js/payment_cashdro.esm";
import {Gui} from "point_of_sale.Gui";
import {_t} from "web.core";
/**
* Dedicated refund (payout) handler added to the CashDro prototype.
*
* Keeping this as a SEPARATE named method (rather than inlining it inside an
* override of cashdro_send_payment_request) means that pos_cashdro_rounding
* can always delegate refunds here regardless of which module loaded last.
* Both modules patch cashdro_send_payment_request directly on the prototype,
* so the last writer wins for that method; but _cashdro_send_refund_request is
* only ever written by this module and is never overwritten.
*/
PaymentCashdro.prototype._cashdro_send_refund_request = async function (order) {
const payment_line = order.selected_paymentline;
const due = order.get_due(payment_line);
// Include cash rounding if applied (same logic as pos_cashdro_rounding for
// normal payments). get_rounding_applied() returns 0 when no rounding is
// configured, so this is safe without pos_cashdro_rounding installed.
const rounding = order.get_rounding_applied ? order.get_rounding_applied() : 0;
const amount = Math.round(Math.abs(due + rounding) * 100);
// Require password authorisation before opening the drawer for a payout.
const {confirmed, payload: password} = await Gui.showPopup(
"PasswordInputPopup",
{
title: _t("CashDro Refund Authorization"),
body: _t("Enter the CashDro password to authorize this refund"),
placeholder: _t("Password"),
}
);
if (!confirmed) {
payment_line.set_payment_status("retry");
return false;
}
const method = payment_line.payment_method;
if (password !== method.cashdro_password) {
await Gui.showPopup("ErrorPopup", {
title: _t("Authentication Failed"),
body: _t("The CashDro password is incorrect."),
});
payment_line.set_payment_status("retry");
return false;
}
const start_url = this._cashdro_payout_url({amount: amount});
console.log(start_url);
const res = await this._cashdro_request(start_url);
console.log(res);
const operation_id = res.data || "";
this.pos.get_order().cashdro_operation = operation_id;
const ack_url = this._cashdro_ack_url(operation_id);
const res_ack = await this._cashdro_request(ack_url);
console.log(res_ack);
const ask_url = this._cashdro_ask_url(operation_id);
const operation_data = await this._cashdro_request_payment(ask_url);
console.log(operation_data);
const data = JSON.parse(operation_data.data);
payment_line.cashdro_operation_data = data;
// CashDro reports the dispensed amount in totalout for payouts.
// The API may return totalout as a negative value (money going out),
// so we use Math.abs to get the magnitude and explicitly set the
// payment line to a negative amount (refund to customer).
const dispensed = Math.abs(data.operation.totalout) / 100;
payment_line.set_amount(-dispensed);
return true;
};
/**
* Build the URL for a type=3 (payout/refund) operation on the CashDro.
*/
PaymentCashdro.prototype._cashdro_payout_url = function (parameters) {
const user = this.pos.get_cashier().id || this.pos.user.id;
let url = `${this._cashdro_url()}&operation=startOperation&type=3`;
url += `&posid=pos-${this.pos.pos_session.name}`;
url += `&posuser=${user}`;
url += `&parameters=${encodeURIComponent(JSON.stringify(parameters))}`;
return url;
};
/**
* Override cashdro_send_payment_request to route refunds to
* _cashdro_send_refund_request.
*
* NOTE: if pos_cashdro_rounding is also installed it will overwrite this
* override (because "rounding" > "refund" alphabetically), but that is fine
* because pos_cashdro_rounding also delegates negative amounts to
* _cashdro_send_refund_request. This override only matters when the rounding
* module is NOT installed.
*/
const _originalSendPaymentRequest =
PaymentCashdro.prototype.cashdro_send_payment_request;
PaymentCashdro.prototype.cashdro_send_payment_request = async function (order) {
const payment_line = order.selected_paymentline;
const due = order.get_due(payment_line);
if (due < 0) {
return this._cashdro_send_refund_request(order);
}
return _originalSendPaymentRequest.call(this, order);
};

View file

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Copyright 2026 Criptomart - extracted from pos_payment_method_cashdro
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -->
<templates id="template" xml:space="preserve">
<t t-name="PasswordInputPopup" owl="1">
<div class="popup popup-textinput">
<header class="title">
<t t-esc="props.title" />
</header>
<div class="body">
<p>
<t t-esc="props.body" />
</p>
<input
type="password"
t-model="state.inputValue"
t-ref="input"
t-att-placeholder="props.placeholder"
/>
</div>
<div class="footer">
<div class="button confirm highlight" t-on-click="confirm">
<t t-esc="props.confirmText" />
</div>
<div class="button cancel" t-on-click="cancel">
<t t-esc="props.cancelText" />
</div>
</div>
</div>
</t>
</templates>

View file

@ -11,6 +11,17 @@ import { PaymentCashdro } from "@pos_payment_method_cashdro/js/payment_cashdro.e
PaymentCashdro.prototype.cashdro_send_payment_request = async function (order) { PaymentCashdro.prototype.cashdro_send_payment_request = async function (order) {
const payment_line = order.selected_paymentline; const payment_line = order.selected_paymentline;
try { try {
// If pos_cashdro_refund is installed this method handles payouts.
// Rounding is not applicable to refunds so we delegate and return.
const due = order.get_due(payment_line);
if (due < 0) {
if (this._cashdro_send_refund_request) {
return this._cashdro_send_refund_request(order);
}
// Refund module not installed – nothing we can do.
payment_line.set_payment_status("retry");
return false;
}
// Cashdro treats decimals as positions in an integer we also have // Cashdro treats decimals as positions in an integer we also have
// to deal with floating point computing to avoid decimals at the // to deal with floating point computing to avoid decimals at the
// end or the drawer will reject our request. // end or the drawer will reject our request.

View file

@ -0,0 +1,29 @@
# POS Dashboard Cooperative
Real-time BI dashboards for cooperative supermarkets, built on top of
Point of Sale data.
This module extends `report.pos.order` with three new dimensions:
| Field | Description |
|---|---|
| `is_member_at_date` | True if the customer held a `share.line` whose `effective_date` was on or before the order date. |
| `categ_root_id` | Product family (root of `product.category` parent_path). |
| `categ_parent_id` | Product subfamily (parent of the leaf category). |
| `date_day` | Order date truncated to day. |
| `hour_of_day` | Hour of the order (`EXTRACT(HOUR FROM date_order)`). |
| `day_of_week` | Day of week, 1 = Monday … 7 = Sunday (ISO). |
Pre-configured menus under *Point of Sale → Reporting → Cooperative
Dashboards* cover overview, members vs. non-members, categories, top
products, and time distributions. Four tiles surface the headline KPIs
on the `web_dashboard_tile` board.
For spreadsheet dashboards published in Odoo's *Dashboards* menu, install
the companion module `spreadsheet_dashboard_pos_cooperative`.
See `readme/` for the OCA-style structured documentation.
## License
AGPL-3.0 or later.

Some files were not shown because too many files have changed in this diff Show more