diff --git a/hr_timesheet_sheet_delete_cascade/__init__.py b/hr_timesheet_sheet_delete_cascade/__init__.py
new file mode 100644
index 0000000..aee8895
--- /dev/null
+++ b/hr_timesheet_sheet_delete_cascade/__init__.py
@@ -0,0 +1,2 @@
+from . import models
+from . import wizards
diff --git a/hr_timesheet_sheet_delete_cascade/__manifest__.py b/hr_timesheet_sheet_delete_cascade/__manifest__.py
new file mode 100644
index 0000000..e23d999
--- /dev/null
+++ b/hr_timesheet_sheet_delete_cascade/__manifest__.py
@@ -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,
+}
diff --git a/hr_timesheet_sheet_delete_cascade/i18n/es.po b/hr_timesheet_sheet_delete_cascade/i18n/es.po
new file mode 100644
index 0000000..9f206dc
--- /dev/null
+++ b/hr_timesheet_sheet_delete_cascade/i18n/es.po
@@ -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 ""
+""
+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."
\ No newline at end of file
diff --git a/hr_timesheet_sheet_delete_cascade/i18n/hr_timesheet_sheet_delete_cascade.pot b/hr_timesheet_sheet_delete_cascade/i18n/hr_timesheet_sheet_delete_cascade.pot
new file mode 100644
index 0000000..1d2c870
--- /dev/null
+++ b/hr_timesheet_sheet_delete_cascade/i18n/hr_timesheet_sheet_delete_cascade.pot
@@ -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 ""
+""
+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 ""
diff --git a/hr_timesheet_sheet_delete_cascade/models/__init__.py b/hr_timesheet_sheet_delete_cascade/models/__init__.py
new file mode 100644
index 0000000..dcdb896
--- /dev/null
+++ b/hr_timesheet_sheet_delete_cascade/models/__init__.py
@@ -0,0 +1,4 @@
+from . import res_company
+from . import res_config_settings
+from . import account_analytic_line
+from . import hr_timesheet_sheet
diff --git a/hr_timesheet_sheet_delete_cascade/models/account_analytic_line.py b/hr_timesheet_sheet_delete_cascade/models/account_analytic_line.py
new file mode 100644
index 0000000..085b643
--- /dev/null
+++ b/hr_timesheet_sheet_delete_cascade/models/account_analytic_line.py
@@ -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()
diff --git a/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py b/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py
new file mode 100644
index 0000000..cd2303d
--- /dev/null
+++ b/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py
@@ -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,
+ ),
+ }
diff --git a/hr_timesheet_sheet_delete_cascade/models/res_company.py b/hr_timesheet_sheet_delete_cascade/models/res_company.py
new file mode 100644
index 0000000..b693fb7
--- /dev/null
+++ b/hr_timesheet_sheet_delete_cascade/models/res_company.py
@@ -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.",
+ )
diff --git a/hr_timesheet_sheet_delete_cascade/models/res_config_settings.py b/hr_timesheet_sheet_delete_cascade/models/res_config_settings.py
new file mode 100644
index 0000000..814dcf2
--- /dev/null
+++ b/hr_timesheet_sheet_delete_cascade/models/res_config_settings.py
@@ -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,
+ )
diff --git a/hr_timesheet_sheet_delete_cascade/readme/CONFIGURE.rst b/hr_timesheet_sheet_delete_cascade/readme/CONFIGURE.rst
new file mode 100644
index 0000000..c4cb504
--- /dev/null
+++ b/hr_timesheet_sheet_delete_cascade/readme/CONFIGURE.rst
@@ -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.
diff --git a/hr_timesheet_sheet_delete_cascade/readme/CONTRIBUTORS.rst b/hr_timesheet_sheet_delete_cascade/readme/CONTRIBUTORS.rst
new file mode 100644
index 0000000..3c75247
--- /dev/null
+++ b/hr_timesheet_sheet_delete_cascade/readme/CONTRIBUTORS.rst
@@ -0,0 +1,3 @@
+* Criptomart
+
+ * Luis Martínez
diff --git a/hr_timesheet_sheet_delete_cascade/readme/DESCRIPTION.rst b/hr_timesheet_sheet_delete_cascade/readme/DESCRIPTION.rst
new file mode 100644
index 0000000..d17cddb
--- /dev/null
+++ b/hr_timesheet_sheet_delete_cascade/readme/DESCRIPTION.rst
@@ -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.
diff --git a/hr_timesheet_sheet_delete_cascade/readme/ROADMAP.rst b/hr_timesheet_sheet_delete_cascade/readme/ROADMAP.rst
new file mode 100644
index 0000000..7002136
--- /dev/null
+++ b/hr_timesheet_sheet_delete_cascade/readme/ROADMAP.rst
@@ -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.
diff --git a/hr_timesheet_sheet_delete_cascade/readme/USAGE.rst b/hr_timesheet_sheet_delete_cascade/readme/USAGE.rst
new file mode 100644
index 0000000..0628894
--- /dev/null
+++ b/hr_timesheet_sheet_delete_cascade/readme/USAGE.rst
@@ -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.
diff --git a/hr_timesheet_sheet_delete_cascade/security/hr_timesheet_sheet_security.xml b/hr_timesheet_sheet_delete_cascade/security/hr_timesheet_sheet_security.xml
new file mode 100644
index 0000000..b4eb750
--- /dev/null
+++ b/hr_timesheet_sheet_delete_cascade/security/hr_timesheet_sheet_security.xml
@@ -0,0 +1,30 @@
+
+
+
+
+ Timesheet sheet: only own sheets can be changed
+
+ [('employee_id.user_id', '=', user.id)]
+
+
+
+
+
+
+
+ Timesheet sheet: managers can change every sheet
+
+ [(1, '=', 1)]
+
+
+
+
+
+
+
diff --git a/hr_timesheet_sheet_delete_cascade/security/ir.model.access.csv b/hr_timesheet_sheet_delete_cascade/security/ir.model.access.csv
new file mode 100644
index 0000000..c6ea578
--- /dev/null
+++ b/hr_timesheet_sheet_delete_cascade/security/ir.model.access.csv
@@ -0,0 +1,2 @@
+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+access_hr_timesheet_sheet_delete_wizard,hr_timesheet.sheet.delete.wizard,model_hr_timesheet_sheet_delete_wizard,base.group_user,1,1,1,1
diff --git a/hr_timesheet_sheet_delete_cascade/tests/__init__.py b/hr_timesheet_sheet_delete_cascade/tests/__init__.py
new file mode 100644
index 0000000..8855e3a
--- /dev/null
+++ b/hr_timesheet_sheet_delete_cascade/tests/__init__.py
@@ -0,0 +1 @@
+from . import test_hr_timesheet_sheet_delete_cascade
diff --git a/hr_timesheet_sheet_delete_cascade/tests/test_hr_timesheet_sheet_delete_cascade.py b/hr_timesheet_sheet_delete_cascade/tests/test_hr_timesheet_sheet_delete_cascade.py
new file mode 100644
index 0000000..c431db9
--- /dev/null
+++ b/hr_timesheet_sheet_delete_cascade/tests/test_hr_timesheet_sheet_delete_cascade.py
@@ -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()
+ )
diff --git a/hr_timesheet_sheet_delete_cascade/views/hr_timesheet_sheet_views.xml b/hr_timesheet_sheet_delete_cascade/views/hr_timesheet_sheet_views.xml
new file mode 100644
index 0000000..76f947b
--- /dev/null
+++ b/hr_timesheet_sheet_delete_cascade/views/hr_timesheet_sheet_views.xml
@@ -0,0 +1,62 @@
+
+
+
+
+ hr_timesheet.sheet.form.delete.cascade
+ hr_timesheet.sheet
+
+
+
+
+
+
+
+
+
+ hr_timesheet.sheet.tree.delete.cascade
+ hr_timesheet.sheet
+
+
+
+ 0
+
+
+
+
+ hr_timesheet.sheet.tree.my.delete.cascade
+ hr_timesheet.sheet
+
+
+
+ 0
+
+
+
+
+ hr_timesheet.sheet.kanban.delete.cascade
+ hr_timesheet.sheet
+
+
+
+ 0
+
+
+
+
diff --git a/hr_timesheet_sheet_delete_cascade/views/res_config_settings_views.xml b/hr_timesheet_sheet_delete_cascade/views/res_config_settings_views.xml
new file mode 100644
index 0000000..6b51a7a
--- /dev/null
+++ b/hr_timesheet_sheet_delete_cascade/views/res_config_settings_views.xml
@@ -0,0 +1,52 @@
+
+
+
+
+ res.config.settings.form.delete.cascade
+ res.config.settings
+
+
+
+
+
+
+
+
+
+ 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.
+
") % _("No timesheet sheet was selected.")
+ parts = []
+ for section in data["sections"]:
+ parts.append(Markup("
%s
") % section["title"])
+ parts.append(
+ Markup("
%s
")
+ % Markup("").join(
+ Markup("
%s
") % 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
diff --git a/hr_timesheet_sheet_delete_cascade/wizards/delete_sheet_wizard_views.xml b/hr_timesheet_sheet_delete_cascade/wizards/delete_sheet_wizard_views.xml
new file mode 100644
index 0000000..c6fe892
--- /dev/null
+++ b/hr_timesheet_sheet_delete_cascade/wizards/delete_sheet_wizard_views.xml
@@ -0,0 +1,65 @@
+
+
+
+
+ hr_timesheet.sheet.delete.wizard.form
+ hr_timesheet.sheet.delete.wizard
+
+
+
+
+
+ Delete Timesheet Sheets
+ hr_timesheet.sheet.delete.wizard
+ form
+
+ new
+
+
+ list,kanban
+
+
diff --git a/pos_require_product_quantity/__init__.py b/hr_timesheet_sheet_holidays/__init__.py
similarity index 100%
rename from pos_require_product_quantity/__init__.py
rename to hr_timesheet_sheet_holidays/__init__.py
diff --git a/hr_timesheet_sheet_holidays/__manifest__.py b/hr_timesheet_sheet_holidays/__manifest__.py
new file mode 100644
index 0000000..be293db
--- /dev/null
+++ b/hr_timesheet_sheet_holidays/__manifest__.py
@@ -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",
+}
diff --git a/hr_timesheet_sheet_holidays/i18n/es.po b/hr_timesheet_sheet_holidays/i18n/es.po
new file mode 100644
index 0000000..ea4047b
--- /dev/null
+++ b/hr_timesheet_sheet_holidays/i18n/es.po
@@ -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"
diff --git a/hr_timesheet_sheet_holidays/i18n/hr_timesheet_sheet_holidays.pot b/hr_timesheet_sheet_holidays/i18n/hr_timesheet_sheet_holidays.pot
new file mode 100644
index 0000000..c40de26
--- /dev/null
+++ b/hr_timesheet_sheet_holidays/i18n/hr_timesheet_sheet_holidays.pot
@@ -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 ""
diff --git a/hr_timesheet_sheet_holidays/models/__init__.py b/hr_timesheet_sheet_holidays/models/__init__.py
new file mode 100644
index 0000000..f51115c
--- /dev/null
+++ b/hr_timesheet_sheet_holidays/models/__init__.py
@@ -0,0 +1,2 @@
+from . import account_analytic_line
+from . import hr_timesheet_sheet
diff --git a/hr_timesheet_sheet_holidays/models/account_analytic_line.py b/hr_timesheet_sheet_holidays/models/account_analytic_line.py
new file mode 100644
index 0000000..dc43284
--- /dev/null
+++ b/hr_timesheet_sheet_holidays/models/account_analytic_line.py
@@ -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()
diff --git a/hr_timesheet_sheet_holidays/models/hr_timesheet_sheet.py b/hr_timesheet_sheet_holidays/models/hr_timesheet_sheet.py
new file mode 100644
index 0000000..3c8e313
--- /dev/null
+++ b/hr_timesheet_sheet_holidays/models/hr_timesheet_sheet.py
@@ -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)
diff --git a/hr_timesheet_sheet_holidays/readme/CONTRIBUTORS.rst b/hr_timesheet_sheet_holidays/readme/CONTRIBUTORS.rst
new file mode 100644
index 0000000..716d25d
--- /dev/null
+++ b/hr_timesheet_sheet_holidays/readme/CONTRIBUTORS.rst
@@ -0,0 +1,3 @@
+* Criptomart
+
+ * Luis Díez
diff --git a/hr_timesheet_sheet_holidays/readme/DESCRIPTION.rst b/hr_timesheet_sheet_holidays/readme/DESCRIPTION.rst
new file mode 100644
index 0000000..c669031
--- /dev/null
+++ b/hr_timesheet_sheet_holidays/readme/DESCRIPTION.rst
@@ -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.
diff --git a/hr_timesheet_sheet_holidays/readme/ROADMAP.rst b/hr_timesheet_sheet_holidays/readme/ROADMAP.rst
new file mode 100644
index 0000000..e1b2c5f
--- /dev/null
+++ b/hr_timesheet_sheet_holidays/readme/ROADMAP.rst
@@ -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.
diff --git a/hr_timesheet_sheet_holidays/readme/USAGE.rst b/hr_timesheet_sheet_holidays/readme/USAGE.rst
new file mode 100644
index 0000000..185c823
--- /dev/null
+++ b/hr_timesheet_sheet_holidays/readme/USAGE.rst
@@ -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.
diff --git a/hr_timesheet_sheet_holidays/tests/__init__.py b/hr_timesheet_sheet_holidays/tests/__init__.py
new file mode 100644
index 0000000..d0e374d
--- /dev/null
+++ b/hr_timesheet_sheet_holidays/tests/__init__.py
@@ -0,0 +1 @@
+from . import test_hr_timesheet_sheet_holidays
diff --git a/hr_timesheet_sheet_holidays/tests/test_hr_timesheet_sheet_holidays.py b/hr_timesheet_sheet_holidays/tests/test_hr_timesheet_sheet_holidays.py
new file mode 100644
index 0000000..eb10a15
--- /dev/null
+++ b/hr_timesheet_sheet_holidays/tests/test_hr_timesheet_sheet_holidays.py
@@ -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,
+ }
+ )
+ )
diff --git a/hr_timesheet_sheet_no_review/__init__.py b/hr_timesheet_sheet_no_review/__init__.py
new file mode 100644
index 0000000..aee8895
--- /dev/null
+++ b/hr_timesheet_sheet_no_review/__init__.py
@@ -0,0 +1,2 @@
+from . import models
+from . import wizards
diff --git a/hr_timesheet_sheet_no_review/__manifest__.py b/hr_timesheet_sheet_no_review/__manifest__.py
new file mode 100644
index 0000000..f04224b
--- /dev/null
+++ b/hr_timesheet_sheet_no_review/__manifest__.py
@@ -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,
+}
diff --git a/hr_timesheet_sheet_no_review/i18n/hr_timesheet_sheet_no_review.pot b/hr_timesheet_sheet_no_review/i18n/hr_timesheet_sheet_no_review.pot
new file mode 100644
index 0000000..d4cf023
--- /dev/null
+++ b/hr_timesheet_sheet_no_review/i18n/hr_timesheet_sheet_no_review.pot
@@ -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 ""
diff --git a/hr_timesheet_sheet_no_review/models/__init__.py b/hr_timesheet_sheet_no_review/models/__init__.py
new file mode 100644
index 0000000..5858a81
--- /dev/null
+++ b/hr_timesheet_sheet_no_review/models/__init__.py
@@ -0,0 +1 @@
+from . import hr_timesheet_sheet
diff --git a/hr_timesheet_sheet_no_review/models/hr_timesheet_sheet.py b/hr_timesheet_sheet_no_review/models/hr_timesheet_sheet.py
new file mode 100644
index 0000000..26a289b
--- /dev/null
+++ b/hr_timesheet_sheet_no_review/models/hr_timesheet_sheet.py
@@ -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
diff --git a/hr_timesheet_sheet_no_review/readme/CONTRIBUTORS.rst b/hr_timesheet_sheet_no_review/readme/CONTRIBUTORS.rst
new file mode 100644
index 0000000..4570a38
--- /dev/null
+++ b/hr_timesheet_sheet_no_review/readme/CONTRIBUTORS.rst
@@ -0,0 +1,3 @@
+* Criptomart
+
+ * Luis Martín
diff --git a/hr_timesheet_sheet_no_review/readme/DESCRIPTION.rst b/hr_timesheet_sheet_no_review/readme/DESCRIPTION.rst
new file mode 100644
index 0000000..3935055
--- /dev/null
+++ b/hr_timesheet_sheet_no_review/readme/DESCRIPTION.rst
@@ -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.
diff --git a/hr_timesheet_sheet_no_review/readme/ROADMAP.rst b/hr_timesheet_sheet_no_review/readme/ROADMAP.rst
new file mode 100644
index 0000000..02d2146
--- /dev/null
+++ b/hr_timesheet_sheet_no_review/readme/ROADMAP.rst
@@ -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.
diff --git a/hr_timesheet_sheet_no_review/readme/USAGE.rst b/hr_timesheet_sheet_no_review/readme/USAGE.rst
new file mode 100644
index 0000000..7636b24
--- /dev/null
+++ b/hr_timesheet_sheet_no_review/readme/USAGE.rst
@@ -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.
diff --git a/hr_timesheet_sheet_no_review/security/ir.model.access.csv b/hr_timesheet_sheet_no_review/security/ir.model.access.csv
new file mode 100644
index 0000000..e4bfe73
--- /dev/null
+++ b/hr_timesheet_sheet_no_review/security/ir.model.access.csv
@@ -0,0 +1,2 @@
+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+access_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
diff --git a/hr_timesheet_sheet_no_review/tests/__init__.py b/hr_timesheet_sheet_no_review/tests/__init__.py
new file mode 100644
index 0000000..1041210
--- /dev/null
+++ b/hr_timesheet_sheet_no_review/tests/__init__.py
@@ -0,0 +1 @@
+from . import test_hr_timesheet_sheet_no_review
diff --git a/hr_timesheet_sheet_no_review/tests/test_hr_timesheet_sheet_no_review.py b/hr_timesheet_sheet_no_review/tests/test_hr_timesheet_sheet_no_review.py
new file mode 100644
index 0000000..5e86812
--- /dev/null
+++ b/hr_timesheet_sheet_no_review/tests/test_hr_timesheet_sheet_no_review.py
@@ -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())
diff --git a/hr_timesheet_sheet_no_review/views/hr_timesheet_sheet_views.xml b/hr_timesheet_sheet_no_review/views/hr_timesheet_sheet_views.xml
new file mode 100644
index 0000000..d9796f6
--- /dev/null
+++ b/hr_timesheet_sheet_no_review/views/hr_timesheet_sheet_views.xml
@@ -0,0 +1,103 @@
+
+
+
+
+ hr_timesheet.sheet.form.no.review
+ hr_timesheet.sheet
+
+
+
+
+
+
+
+ draft
+
+
+
+
+ hr_timesheet.sheet.kanban.no.review
+ hr_timesheet.sheet
+
+
+
+
+
+
+
+
+
+ hr_timesheet.sheet.tree.no.review
+ hr_timesheet.sheet
+
+
+
+
+
+
+
+
+
+ hr_timesheet.sheet.filter.no.review
+ hr_timesheet.sheet
+
+
+
+
+
+
+
+
+
+ account.analytic.line.search.no.review
+ account.analytic.line
+
+
+
+
+
+
+
+ hr.department.kanban.no.review
+ hr.department
+
+
+
+
+
+
+
+
diff --git a/hr_timesheet_sheet_no_review/views/menus.xml b/hr_timesheet_sheet_no_review/views/menus.xml
new file mode 100644
index 0000000..8a978a4
--- /dev/null
+++ b/hr_timesheet_sheet_no_review/views/menus.xml
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/hr_timesheet_sheet_no_review/wizards/__init__.py b/hr_timesheet_sheet_no_review/wizards/__init__.py
new file mode 100644
index 0000000..4eea750
--- /dev/null
+++ b/hr_timesheet_sheet_no_review/wizards/__init__.py
@@ -0,0 +1 @@
+from . import unfreeze_wizard
diff --git a/hr_timesheet_sheet_no_review/wizards/unfreeze_wizard.py b/hr_timesheet_sheet_no_review/wizards/unfreeze_wizard.py
new file mode 100644
index 0000000..d416601
--- /dev/null
+++ b/hr_timesheet_sheet_no_review/wizards/unfreeze_wizard.py
@@ -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("
%s
") % _(
+ "There is no frozen timesheet sheet. Nothing to unfreeze."
+ )
+ return Markup("
%s
") % _(
+ "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"},
+ },
+ }
diff --git a/hr_timesheet_sheet_no_review/wizards/unfreeze_wizard_views.xml b/hr_timesheet_sheet_no_review/wizards/unfreeze_wizard_views.xml
new file mode 100644
index 0000000..6afa54d
--- /dev/null
+++ b/hr_timesheet_sheet_no_review/wizards/unfreeze_wizard_views.xml
@@ -0,0 +1,56 @@
+
+
+
+
+ hr_timesheet.sheet.unfreeze.wizard.form
+ hr_timesheet.sheet.unfreeze.wizard
+
+
+
+
+
+ Unfreeze Pending Sheets
+ hr_timesheet.sheet.unfreeze.wizard
+ form
+
+ new
+
+
diff --git a/pos_hide_partner_info/__init__.py b/mail_quoted_reply_icon/__init__.py
similarity index 100%
rename from pos_hide_partner_info/__init__.py
rename to mail_quoted_reply_icon/__init__.py
diff --git a/mail_quoted_reply_icon/__manifest__.py b/mail_quoted_reply_icon/__manifest__.py
new file mode 100644
index 0000000..2feb759
--- /dev/null
+++ b/mail_quoted_reply_icon/__manifest__.py
@@ -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",
+ ],
+ },
+}
diff --git a/mail_quoted_reply_icon/static/src/models/message_action_view.esm.js b/mail_quoted_reply_icon/static/src/models/message_action_view.esm.js
new file mode 100644
index 0000000..cf8ee56
--- /dev/null
+++ b/mail_quoted_reply_icon/static/src/models/message_action_view.esm.js
@@ -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();
+ },
+ },
+ },
+});
diff --git a/partner_email_sync_portal_login/README.md b/partner_email_sync_portal_login/README.md
new file mode 100644
index 0000000..7214f18
--- /dev/null
+++ b/partner_email_sync_portal_login/README.md
@@ -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.
diff --git a/partner_email_sync_portal_login/__init__.py b/partner_email_sync_portal_login/__init__.py
new file mode 100644
index 0000000..9b42961
--- /dev/null
+++ b/partner_email_sync_portal_login/__init__.py
@@ -0,0 +1,2 @@
+from . import models
+from . import wizard
diff --git a/partner_email_sync_portal_login/__manifest__.py b/partner_email_sync_portal_login/__manifest__.py
new file mode 100644
index 0000000..b5ea4c6
--- /dev/null
+++ b/partner_email_sync_portal_login/__manifest__.py
@@ -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,
+}
diff --git a/partner_email_sync_portal_login/i18n/es.po b/partner_email_sync_portal_login/i18n/es.po
new file mode 100644
index 0000000..d7269cf
--- /dev/null
+++ b/partner_email_sync_portal_login/i18n/es.po
@@ -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."
diff --git a/partner_email_sync_portal_login/i18n/partner_email_sync_portal_login.pot b/partner_email_sync_portal_login/i18n/partner_email_sync_portal_login.pot
new file mode 100644
index 0000000..90e3269
--- /dev/null
+++ b/partner_email_sync_portal_login/i18n/partner_email_sync_portal_login.pot
@@ -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 ""
diff --git a/partner_email_sync_portal_login/models/__init__.py b/partner_email_sync_portal_login/models/__init__.py
new file mode 100644
index 0000000..91fed54
--- /dev/null
+++ b/partner_email_sync_portal_login/models/__init__.py
@@ -0,0 +1 @@
+from . import res_partner
diff --git a/partner_email_sync_portal_login/models/res_partner.py b/partner_email_sync_portal_login/models/res_partner.py
new file mode 100644
index 0000000..e22276e
--- /dev/null
+++ b/partner_email_sync_portal_login/models/res_partner.py
@@ -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("
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%.
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.
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.
Bugs are tracked on GitHub Issues.
In case of trouble, please check there if your issue has already been reported.
-If you spotted it first, help us smashing it by providing a detailed and welcomed
-feedback.
+If you spotted it first, help us to smash it by providing a detailed and welcomed
+feedback.
Do not contact contributors directly about support or help with technical issues.
OCA, or the Odoo Community Association, is a nonprofit organization whose
-mission is to support the collaborative development of Odoo features and
-promote its widespread use.
-
This module is part of the OCA/pos project on GitHub.
+ 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.
+
+
+
+
+
+
+
diff --git a/pos_cashdro_refund/__init__.py b/pos_cashdro_refund/__init__.py
new file mode 100644
index 0000000..efb0bb1
--- /dev/null
+++ b/pos_cashdro_refund/__init__.py
@@ -0,0 +1,2 @@
+# Copyright 2026 Criptomart
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
diff --git a/pos_cashdro_refund/__manifest__.py b/pos_cashdro_refund/__manifest__.py
new file mode 100644
index 0000000..5914bbe
--- /dev/null
+++ b/pos_cashdro_refund/__manifest__.py
@@ -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,
+}
diff --git a/pos_cashdro_refund/static/src/js/PasswordInputPopup.js b/pos_cashdro_refund/static/src/js/PasswordInputPopup.js
new file mode 100644
index 0000000..0dfb636
--- /dev/null
+++ b/pos_cashdro_refund/static/src/js/PasswordInputPopup.js
@@ -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;
+});
diff --git a/pos_cashdro_refund/static/src/js/payment_cashdro_refund.esm.js b/pos_cashdro_refund/static/src/js/payment_cashdro_refund.esm.js
new file mode 100644
index 0000000..05e812b
--- /dev/null
+++ b/pos_cashdro_refund/static/src/js/payment_cashdro_refund.esm.js
@@ -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 += `¶meters=${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);
+};
diff --git a/pos_cashdro_refund/static/src/xml/PasswordInputPopup.xml b/pos_cashdro_refund/static/src/xml/PasswordInputPopup.xml
new file mode 100644
index 0000000..3e6358c
--- /dev/null
+++ b/pos_cashdro_refund/static/src/xml/PasswordInputPopup.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pos_cashdro_rounding/static/src/js/cashdro_rounding.esm.js b/pos_cashdro_rounding/static/src/js/cashdro_rounding.esm.js
index e117a1f..222c9f2 100644
--- a/pos_cashdro_rounding/static/src/js/cashdro_rounding.esm.js
+++ b/pos_cashdro_rounding/static/src/js/cashdro_rounding.esm.js
@@ -11,6 +11,17 @@ import { PaymentCashdro } from "@pos_payment_method_cashdro/js/payment_cashdro.e
PaymentCashdro.prototype.cashdro_send_payment_request = async function (order) {
const payment_line = order.selected_paymentline;
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
// to deal with floating point computing to avoid decimals at the
// end or the drawer will reject our request.
diff --git a/pos_dashboard_cooperative/README.md b/pos_dashboard_cooperative/README.md
new file mode 100644
index 0000000..ca37e3f
--- /dev/null
+++ b/pos_dashboard_cooperative/README.md
@@ -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.
diff --git a/pos_dashboard_cooperative/__init__.py b/pos_dashboard_cooperative/__init__.py
new file mode 100644
index 0000000..4c4f242
--- /dev/null
+++ b/pos_dashboard_cooperative/__init__.py
@@ -0,0 +1 @@
+from . import report
diff --git a/pos_dashboard_cooperative/__manifest__.py b/pos_dashboard_cooperative/__manifest__.py
new file mode 100644
index 0000000..ce78c5d
--- /dev/null
+++ b/pos_dashboard_cooperative/__manifest__.py
@@ -0,0 +1,27 @@
+# Copyright 2026 Criptomart
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+{
+ "name": "POS Dashboard Cooperative",
+ "version": "16.0.1.0.0",
+ "summary": "Real-time BI dashboards for cooperative supermarkets (POS)",
+ "license": "AGPL-3",
+ "author": "Criptomart, Odoo Community Association (OCA)",
+ "website": "https://github.com/OCA/pos",
+ "category": "Point of Sale",
+ "depends": [
+ "point_of_sale",
+ "cooperator",
+ "pos_margin",
+ "web_dashboard_tile",
+ "web_pivot_computed_measure",
+ ],
+ "data": [
+ "report/pos_order_report_views.xml",
+ "views/pos_order_report_member_views.xml",
+ "views/pos_order_report_category_views.xml",
+ "views/pos_order_report_time_views.xml",
+ "views/dashboard_tiles.xml",
+ "views/menus.xml",
+ ],
+ "installable": True,
+}
diff --git a/pos_dashboard_cooperative/i18n/es.po b/pos_dashboard_cooperative/i18n/es.po
new file mode 100644
index 0000000..19431dc
--- /dev/null
+++ b/pos_dashboard_cooperative/i18n/es.po
@@ -0,0 +1,151 @@
+# Translation of Odoo Server.
+# This file contains the translation of the following modules:
+# * pos_dashboard_cooperative
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: Odoo Server 16.0\n"
+"Report-Msgid-Bugs-To: \n"
+"PO-Revision-Date: 2026-06-05 12:00+0000\n"
+"Last-Translator: Criptomart\n"
+"Language-Team: Spanish\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: pos_dashboard_cooperative
+#: model:ir.actions.act_window,name:pos_dashboard_cooperative.action_pos_dashboard_by_day_of_week
+msgid "Sales by Day of Week"
+msgstr "Ventas por día de la semana"
+
+#. module: pos_dashboard_cooperative
+#: model:ir.actions.act_window,name:pos_dashboard_cooperative.action_pos_dashboard_by_hour
+msgid "Sales by Hour of Day"
+msgstr "Ventas por hora del día"
+
+#. module: pos_dashboard_cooperative
+#: model:ir.actions.act_window,name:pos_dashboard_cooperative.action_pos_dashboard_categories
+msgid "Sales by Category"
+msgstr "Ventas por categoría"
+
+#. module: pos_dashboard_cooperative
+#: model:ir.actions.act_window,name:pos_dashboard_cooperative.action_pos_dashboard_members
+msgid "Members vs Non-members"
+msgstr "Socios vs no socios"
+
+#. module: pos_dashboard_cooperative
+#: model:ir.actions.act_window,name:pos_dashboard_cooperative.action_pos_dashboard_overview
+msgid "Cooperative POS Overview"
+msgstr "Vista general TPV cooperativa"
+
+#. module: pos_dashboard_cooperative
+#: model:ir.actions.act_window,name:pos_dashboard_cooperative.action_pos_dashboard_top_members
+msgid "Top Member Buyers"
+msgstr "Top socias compradoras"
+
+#. module: pos_dashboard_cooperative
+#: model:ir.actions.act_window,name:pos_dashboard_cooperative.action_pos_dashboard_top_products
+msgid "Top Products"
+msgstr "Top productos"
+
+#. module: pos_dashboard_cooperative
+#: model:ir.model,name:pos_dashboard_cooperative.model_report_pos_order
+msgid "Point of Sale Orders Report"
+msgstr "Informe de pedidos del TPV"
+
+#. module: pos_dashboard_cooperative
+#: model:ir.model.fields,field_description:pos_dashboard_cooperative.field_report_pos_order__categ_parent_id
+msgid "Product Subfamily (parent)"
+msgstr "Subfamilia de producto (padre)"
+
+#. module: pos_dashboard_cooperative
+#: model:ir.model.fields,field_description:pos_dashboard_cooperative.field_report_pos_order__categ_root_id
+msgid "Product Family (root)"
+msgstr "Familia de producto (raíz)"
+
+#. module: pos_dashboard_cooperative
+#: model:ir.model.fields,field_description:pos_dashboard_cooperative.field_report_pos_order__date_day
+msgid "Order Day"
+msgstr "Día del pedido"
+
+#. module: pos_dashboard_cooperative
+#: model:ir.model.fields,field_description:pos_dashboard_cooperative.field_report_pos_order__day_of_week
+msgid "Day of Week (1=Mon, 7=Sun)"
+msgstr "Día de la semana (1=lun, 7=dom)"
+
+#. module: pos_dashboard_cooperative
+#: model:ir.model.fields,field_description:pos_dashboard_cooperative.field_report_pos_order__hour_of_day
+msgid "Hour of Day"
+msgstr "Hora del día"
+
+#. module: pos_dashboard_cooperative
+#: model:ir.model.fields,field_description:pos_dashboard_cooperative.field_report_pos_order__is_member_at_date
+msgid "Member at Order Date"
+msgstr "Socio en la fecha del pedido"
+
+#. module: pos_dashboard_cooperative
+#: model:ir.ui.menu,name:pos_dashboard_cooperative.menu_pos_dashboard_by_day
+msgid "By Day of Week"
+msgstr "Por día de la semana"
+
+#. module: pos_dashboard_cooperative
+#: model:ir.ui.menu,name:pos_dashboard_cooperative.menu_pos_dashboard_by_hour
+msgid "By Hour of Day"
+msgstr "Por hora del día"
+
+#. module: pos_dashboard_cooperative
+#: model:ir.ui.menu,name:pos_dashboard_cooperative.menu_pos_dashboard_categories
+msgid "By Category"
+msgstr "Por categoría"
+
+#. module: pos_dashboard_cooperative
+#: model:ir.ui.menu,name:pos_dashboard_cooperative.menu_pos_dashboard_cooperative
+msgid "Cooperative Dashboards"
+msgstr "Tableros de la cooperativa"
+
+#. module: pos_dashboard_cooperative
+#: model:ir.ui.menu,name:pos_dashboard_cooperative.menu_pos_dashboard_members
+msgid "Members vs Non-members"
+msgstr "Socios vs no socios"
+
+#. module: pos_dashboard_cooperative
+#: model:ir.ui.menu,name:pos_dashboard_cooperative.menu_pos_dashboard_overview
+msgid "Overview"
+msgstr "Vista general"
+
+#. module: pos_dashboard_cooperative
+#: model:ir.ui.menu,name:pos_dashboard_cooperative.menu_pos_dashboard_top_members
+msgid "Top Members"
+msgstr "Top socias"
+
+#. module: pos_dashboard_cooperative
+#: model:ir.ui.menu,name:pos_dashboard_cooperative.menu_pos_dashboard_top_products
+msgid "Top Products"
+msgstr "Top productos"
+
+#. module: pos_dashboard_cooperative
+#: model:tile.category,name:pos_dashboard_cooperative.tile_category_cooperative
+msgid "Cooperative POS"
+msgstr "TPV cooperativa"
+
+#. module: pos_dashboard_cooperative
+#: model:tile.tile,name:pos_dashboard_cooperative.tile_pos_margin_last_30_days
+msgid "Margin last 30 days (€)"
+msgstr "Margen últimos 30 días (€)"
+
+#. module: pos_dashboard_cooperative
+#: model:tile.tile,name:pos_dashboard_cooperative.tile_pos_sales_last_30_days
+msgid "Sales last 30 days (€)"
+msgstr "Ventas últimos 30 días (€)"
+
+#. module: pos_dashboard_cooperative
+#: model:tile.tile,name:pos_dashboard_cooperative.tile_pos_sales_members_last_30_days
+msgid "Members sales last 30 days (€)"
+msgstr "Ventas a socias últimos 30 días (€)"
+
+#. module: pos_dashboard_cooperative
+#: model:tile.tile,name:pos_dashboard_cooperative.tile_pos_sales_non_members_last_30_days
+msgid "Non-members sales last 30 days (€)"
+msgstr "Ventas a no socias últimos 30 días (€)"
diff --git a/pos_dashboard_cooperative/i18n/pos_dashboard_cooperative.pot b/pos_dashboard_cooperative/i18n/pos_dashboard_cooperative.pot
new file mode 100644
index 0000000..9875547
--- /dev/null
+++ b/pos_dashboard_cooperative/i18n/pos_dashboard_cooperative.pot
@@ -0,0 +1,149 @@
+# Translation of Odoo Server.
+# This file contains the translation of the following modules:
+# * pos_dashboard_cooperative
+#
+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: pos_dashboard_cooperative
+#: model:ir.actions.act_window,name:pos_dashboard_cooperative.action_pos_dashboard_by_day_of_week
+msgid "Sales by Day of Week"
+msgstr ""
+
+#. module: pos_dashboard_cooperative
+#: model:ir.actions.act_window,name:pos_dashboard_cooperative.action_pos_dashboard_by_hour
+msgid "Sales by Hour of Day"
+msgstr ""
+
+#. module: pos_dashboard_cooperative
+#: model:ir.actions.act_window,name:pos_dashboard_cooperative.action_pos_dashboard_categories
+msgid "Sales by Category"
+msgstr ""
+
+#. module: pos_dashboard_cooperative
+#: model:ir.actions.act_window,name:pos_dashboard_cooperative.action_pos_dashboard_members
+msgid "Members vs Non-members"
+msgstr ""
+
+#. module: pos_dashboard_cooperative
+#: model:ir.actions.act_window,name:pos_dashboard_cooperative.action_pos_dashboard_overview
+msgid "Cooperative POS Overview"
+msgstr ""
+
+#. module: pos_dashboard_cooperative
+#: model:ir.actions.act_window,name:pos_dashboard_cooperative.action_pos_dashboard_top_members
+msgid "Top Member Buyers"
+msgstr ""
+
+#. module: pos_dashboard_cooperative
+#: model:ir.actions.act_window,name:pos_dashboard_cooperative.action_pos_dashboard_top_products
+msgid "Top Products"
+msgstr ""
+
+#. module: pos_dashboard_cooperative
+#: model:ir.model,name:pos_dashboard_cooperative.model_report_pos_order
+msgid "Point of Sale Orders Report"
+msgstr ""
+
+#. module: pos_dashboard_cooperative
+#: model:ir.model.fields,field_description:pos_dashboard_cooperative.field_report_pos_order__categ_parent_id
+msgid "Product Subfamily (parent)"
+msgstr ""
+
+#. module: pos_dashboard_cooperative
+#: model:ir.model.fields,field_description:pos_dashboard_cooperative.field_report_pos_order__categ_root_id
+msgid "Product Family (root)"
+msgstr ""
+
+#. module: pos_dashboard_cooperative
+#: model:ir.model.fields,field_description:pos_dashboard_cooperative.field_report_pos_order__date_day
+msgid "Order Day"
+msgstr ""
+
+#. module: pos_dashboard_cooperative
+#: model:ir.model.fields,field_description:pos_dashboard_cooperative.field_report_pos_order__day_of_week
+msgid "Day of Week (1=Mon, 7=Sun)"
+msgstr ""
+
+#. module: pos_dashboard_cooperative
+#: model:ir.model.fields,field_description:pos_dashboard_cooperative.field_report_pos_order__hour_of_day
+msgid "Hour of Day"
+msgstr ""
+
+#. module: pos_dashboard_cooperative
+#: model:ir.model.fields,field_description:pos_dashboard_cooperative.field_report_pos_order__is_member_at_date
+msgid "Member at Order Date"
+msgstr ""
+
+#. module: pos_dashboard_cooperative
+#: model:ir.ui.menu,name:pos_dashboard_cooperative.menu_pos_dashboard_by_day
+msgid "By Day of Week"
+msgstr ""
+
+#. module: pos_dashboard_cooperative
+#: model:ir.ui.menu,name:pos_dashboard_cooperative.menu_pos_dashboard_by_hour
+msgid "By Hour of Day"
+msgstr ""
+
+#. module: pos_dashboard_cooperative
+#: model:ir.ui.menu,name:pos_dashboard_cooperative.menu_pos_dashboard_categories
+msgid "By Category"
+msgstr ""
+
+#. module: pos_dashboard_cooperative
+#: model:ir.ui.menu,name:pos_dashboard_cooperative.menu_pos_dashboard_cooperative
+msgid "Cooperative Dashboards"
+msgstr ""
+
+#. module: pos_dashboard_cooperative
+#: model:ir.ui.menu,name:pos_dashboard_cooperative.menu_pos_dashboard_members
+msgid "Members vs Non-members"
+msgstr ""
+
+#. module: pos_dashboard_cooperative
+#: model:ir.ui.menu,name:pos_dashboard_cooperative.menu_pos_dashboard_overview
+msgid "Overview"
+msgstr ""
+
+#. module: pos_dashboard_cooperative
+#: model:ir.ui.menu,name:pos_dashboard_cooperative.menu_pos_dashboard_top_members
+msgid "Top Members"
+msgstr ""
+
+#. module: pos_dashboard_cooperative
+#: model:ir.ui.menu,name:pos_dashboard_cooperative.menu_pos_dashboard_top_products
+msgid "Top Products"
+msgstr ""
+
+#. module: pos_dashboard_cooperative
+#: model:tile.category,name:pos_dashboard_cooperative.tile_category_cooperative
+msgid "Cooperative POS"
+msgstr ""
+
+#. module: pos_dashboard_cooperative
+#: model:tile.tile,name:pos_dashboard_cooperative.tile_pos_margin_last_30_days
+msgid "Margin last 30 days (€)"
+msgstr ""
+
+#. module: pos_dashboard_cooperative
+#: model:tile.tile,name:pos_dashboard_cooperative.tile_pos_sales_last_30_days
+msgid "Sales last 30 days (€)"
+msgstr ""
+
+#. module: pos_dashboard_cooperative
+#: model:tile.tile,name:pos_dashboard_cooperative.tile_pos_sales_members_last_30_days
+msgid "Members sales last 30 days (€)"
+msgstr ""
+
+#. module: pos_dashboard_cooperative
+#: model:tile.tile,name:pos_dashboard_cooperative.tile_pos_sales_non_members_last_30_days
+msgid "Non-members sales last 30 days (€)"
+msgstr ""
diff --git a/pos_dashboard_cooperative/readme/CONFIGURE.rst b/pos_dashboard_cooperative/readme/CONFIGURE.rst
new file mode 100644
index 0000000..85e3354
--- /dev/null
+++ b/pos_dashboard_cooperative/readme/CONFIGURE.rst
@@ -0,0 +1,7 @@
+No specific configuration is required after installation. The module
+auto-installs ``pos_margin``, ``web_dashboard_tile`` and
+``web_pivot_computed_measure`` if they are not already present.
+
+To control who sees the new dashboards, adjust the standard POS
+security groups (``point_of_sale.group_pos_manager``) — the new analyses
+inherit the access rules already in place for ``report.pos.order``.
diff --git a/pos_dashboard_cooperative/readme/CONTRIBUTORS.rst b/pos_dashboard_cooperative/readme/CONTRIBUTORS.rst
new file mode 100644
index 0000000..c8ac156
--- /dev/null
+++ b/pos_dashboard_cooperative/readme/CONTRIBUTORS.rst
@@ -0,0 +1 @@
+* Criptomart
diff --git a/pos_dashboard_cooperative/readme/DESCRIPTION.rst b/pos_dashboard_cooperative/readme/DESCRIPTION.rst
new file mode 100644
index 0000000..8e92fa6
--- /dev/null
+++ b/pos_dashboard_cooperative/readme/DESCRIPTION.rst
@@ -0,0 +1,24 @@
+Real-time Business Intelligence dashboards for cooperative supermarkets
+built on top of Point of Sale data. Extends the standard
+``report.pos.order`` view to expose new dimensions that the standard POS
+analysis lacks:
+
+* **Cooperative member dimension** — ``is_member_at_date`` flags each
+ ticket line according to the customer's share status *at the order
+ date*, so historic comparisons (members vs. non-members) reflect the
+ reality of the moment.
+* **Category hierarchy** — ``categ_root_id`` (family) and
+ ``categ_parent_id`` (subfamily) derived from
+ ``product.category.parent_path``, ready to drive drilldowns.
+* **Time dimensions** — ``date_day``, ``hour_of_day``, ``day_of_week``
+ for daily/weekly/hourly distributions.
+
+Pre-configured menu entries under *Point of Sale → Reporting →
+Cooperative Dashboards* cover overview, members vs. non-members,
+categories, top products, day-of-week and hour-of-day analyses. Four
+dashboard tiles surface the most important KPIs on the
+``web_dashboard_tile`` board.
+
+The companion module ``spreadsheet_dashboard_pos_cooperative`` publishes
+spreadsheet dashboards in Odoo's *Dashboards* menu that consume the
+same data.
diff --git a/pos_dashboard_cooperative/readme/ROADMAP.rst b/pos_dashboard_cooperative/readme/ROADMAP.rst
new file mode 100644
index 0000000..4a92257
--- /dev/null
+++ b/pos_dashboard_cooperative/readme/ROADMAP.rst
@@ -0,0 +1,12 @@
+* Materialize the SQL view into a regular table populated by a nightly
+ cron, in case real-time aggregation becomes too slow on very large
+ POS datasets.
+* Refine the ``is_member_at_date`` calculation by consuming
+ ``subscription.register`` to subtract sell-back and transfer
+ operations from the share balance. The current implementation simply
+ checks for the existence of a ``share.line`` dated on or before the
+ order date, which over-reports memberships when shares are returned.
+* Add multi-company test coverage once a clean multi-company test
+ fixture is available.
+* Add Galician, Catalan, French, Dutch and Croatian translations
+ (currently shipped with Spanish only).
diff --git a/pos_dashboard_cooperative/readme/USAGE.rst b/pos_dashboard_cooperative/readme/USAGE.rst
new file mode 100644
index 0000000..8e7d40b
--- /dev/null
+++ b/pos_dashboard_cooperative/readme/USAGE.rst
@@ -0,0 +1,39 @@
+Open *Point of Sale → Reporting → Cooperative Dashboards* and pick one
+of the predefined analyses:
+
+* **Overview** — sales, margin and order count grouped by month.
+* **Members vs Non-members** — same axes split by cooperative
+ membership at the order date.
+* **Top Members** — sales aggregated per cooperative member, sort by
+ total to obtain the top buyers.
+* **By Category** — sales by product family (root category); drill down
+ to subfamily and product from the *Group By* menu.
+* **Top Products** — sales per product.
+* **By Day of Week / By Hour of Day** — temporal distributions.
+
+In every view, additional dimensions are available from the *Filters*
+and *Group By* menus: ``Members``, ``Non members``, ``Member``,
+``Product Family``, ``Product Subfamily``, ``Day of Week``,
+``Hour of Day``.
+
+Time dimensions
+===============
+
+* ``day_of_week`` is the ISO day number (``EXTRACT(ISODOW FROM
+ date_order)``): **1 = Monday, 2 = Tuesday, 3 = Wednesday, 4 =
+ Thursday, 5 = Friday, 6 = Saturday, 7 = Sunday**.
+* ``hour_of_day`` is the integer hour in the order's timezone
+ (``EXTRACT(HOUR FROM date_order)``), 0..23.
+
+These are deliberately exposed as integers rather than translated
+labels so they aggregate cleanly in pivots, charts and spreadsheet
+formulas. Map the numbers to weekday names in the consumer (for
+example, in the o-spreadsheet dashboards) if a localized label is
+needed.
+
+Pivot views support the ratios defined by ``web_pivot_computed_measure``
+(e.g. average ticket = total sales / order count). Click *Insert
+Computed Measure* in the pivot's *Measures* menu.
+
+For curated dashboards rendered in Odoo's *Dashboards* menu, install
+the companion module ``spreadsheet_dashboard_pos_cooperative``.
diff --git a/pos_dashboard_cooperative/report/__init__.py b/pos_dashboard_cooperative/report/__init__.py
new file mode 100644
index 0000000..1ddfcaa
--- /dev/null
+++ b/pos_dashboard_cooperative/report/__init__.py
@@ -0,0 +1 @@
+from . import pos_order_report
diff --git a/pos_dashboard_cooperative/report/pos_order_report.py b/pos_dashboard_cooperative/report/pos_order_report.py
new file mode 100644
index 0000000..a208841
--- /dev/null
+++ b/pos_dashboard_cooperative/report/pos_order_report.py
@@ -0,0 +1,130 @@
+# Copyright 2026 Criptomart
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+
+from odoo import fields, models
+
+
+class PosOrderReport(models.Model):
+ _inherit = "report.pos.order"
+
+ # --- Time dimensions ----------------------------------------------------
+ date_day = fields.Date(
+ string="Order Day",
+ readonly=True,
+ )
+ hour_of_day = fields.Integer(
+ string="Hour of Day",
+ readonly=True,
+ group_operator=None,
+ )
+ day_of_week = fields.Integer(
+ string="Day of Week (1=Mon, 7=Sun)",
+ readonly=True,
+ group_operator=None,
+ )
+
+ # --- Category hierarchy -------------------------------------------------
+ # Family = root category, subfamily = direct parent of categ_id.
+ # Computed from product.category.parent_path (materialized path
+ # "1/3/7/"), which is maintained automatically by Odoo (_parent_store).
+ categ_root_id = fields.Many2one(
+ "product.category",
+ string="Product Family (root)",
+ readonly=True,
+ )
+ categ_root_id_int = fields.Integer(
+ string="Product Family ID (root)",
+ readonly=True,
+ group_operator=None,
+ )
+ categ_parent_id = fields.Many2one(
+ "product.category",
+ string="Product Subfamily (parent)",
+ readonly=True,
+ )
+ categ_parent_id_int = fields.Integer(
+ string="Product Subfamily ID (parent)",
+ readonly=True,
+ group_operator=None,
+ )
+ product_categ_id_int = fields.Integer(
+ string="Product Category ID (leaf)",
+ readonly=True,
+ group_operator=None,
+ )
+ product_id_int = fields.Integer(
+ string="Product ID",
+ readonly=True,
+ group_operator=None,
+ )
+
+ # --- Cooperative member dimension --------------------------------------
+ # True when the customer of the ticket held at least one share line whose
+ # effective_date was on or before the order date, in the same company.
+ #
+ # NOTE: this is an approximation. cooperator does not store an end date
+ # on share.line; share buy-backs and transfers are recorded only in
+ # subscription.register. A future version may refine the calculation by
+ # consuming subscription.register to substract sell_back/transfer
+ # operations, at the cost of a more expensive subquery.
+ is_member_at_date = fields.Boolean(
+ string="Member at Order Date",
+ readonly=True,
+ )
+
+ def _select(self):
+ return (
+ super()._select()
+ + """,
+ s.date_order::date AS date_day,
+ EXTRACT(HOUR FROM (s.date_order AT TIME ZONE 'UTC') AT TIME ZONE 'Europe/Madrid')::int AS hour_of_day,
+ EXTRACT(ISODOW FROM s.date_order)::int AS day_of_week,
+ cat_root.id AS categ_root_id,
+ cat_root.id AS categ_root_id_int,
+ pt_categ.parent_id AS categ_parent_id,
+ pt_categ.parent_id AS categ_parent_id_int,
+ pt.categ_id AS product_categ_id_int,
+ pt.id AS product_id_int,
+ CASE WHEN EXISTS (
+ SELECT 1 FROM share_line sl
+ WHERE sl.partner_id = s.partner_id
+ AND sl.company_id = s.company_id
+ AND sl.effective_date IS NOT NULL
+ AND sl.effective_date <= s.date_order::date
+ ) THEN TRUE ELSE FALSE END AS is_member_at_date
+ """
+ )
+
+ def _from(self):
+ # Extra joins to resolve:
+ # - pt_categ: direct category of the product (used for parent_id and
+ # parent_path lookup).
+ # - cat_root: root category, found by taking the first id in
+ # parent_path. parent_path looks like "1/3/7/".
+ return (
+ super()._from()
+ + """
+ LEFT JOIN product_category pt_categ ON pt_categ.id = pt.categ_id
+ LEFT JOIN product_category cat_root
+ ON cat_root.id = NULLIF(
+ split_part(pt_categ.parent_path, '/', 1), ''
+ )::int
+ """
+ )
+
+ def _group_by(self):
+ # Append non-aggregated expressions added in _select() that are not
+ # already in the parent GROUP BY. s.date_order is already there
+ # (point_of_sale.report.pos_order_report._group_by), so the time
+ # dimensions derived from it (date_day, hour_of_day, day_of_week)
+ # are functionally dependent and don't need to be listed.
+ # is_member_at_date is a scalar subquery; not needed either.
+ return (
+ super()._group_by()
+ + """,
+ cat_root.id,
+ pt_categ.parent_id,
+ pt.categ_id,
+ pt.id
+ """
+ )
diff --git a/pos_dashboard_cooperative/report/pos_order_report_views.xml b/pos_dashboard_cooperative/report/pos_order_report_views.xml
new file mode 100644
index 0000000..18a48cb
--- /dev/null
+++ b/pos_dashboard_cooperative/report/pos_order_report_views.xml
@@ -0,0 +1,125 @@
+
+
+
+
+
+
+ report.pos.order.search.cooperative
+ report.pos.order
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ report.pos.order.pivot.cooperative
+ report.pos.order
+
+
+
+
+
+
+
+
+
+
+
+ report.pos.order.tree.cooperative
+ report.pos.order
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Cooperative POS Overview
+ report.pos.order
+ graph,pivot,tree
+
+ {
+ 'search_default_order_month': 1,
+ 'group_by_no_leaf': 1,
+ 'group_by': [],
+ }
+
+
+ No data yet!
+
+ Sell some products at the Point of Sale to populate this
+ cooperative dashboard.
+
+
+
+
+
diff --git a/pos_dashboard_cooperative/tests/__init__.py b/pos_dashboard_cooperative/tests/__init__.py
new file mode 100644
index 0000000..275170b
--- /dev/null
+++ b/pos_dashboard_cooperative/tests/__init__.py
@@ -0,0 +1 @@
+from . import test_pos_dashboard
diff --git a/pos_dashboard_cooperative/tests/test_pos_dashboard.py b/pos_dashboard_cooperative/tests/test_pos_dashboard.py
new file mode 100644
index 0000000..2c85f1a
--- /dev/null
+++ b/pos_dashboard_cooperative/tests/test_pos_dashboard.py
@@ -0,0 +1,215 @@
+# Copyright 2026 Criptomart
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+
+from datetime import date, timedelta
+
+import odoo
+from odoo.tests.common import TransactionCase
+
+
+@odoo.tests.tagged("post_install", "-at_install")
+class TestPosDashboardCooperative(TransactionCase):
+ """Validate the extra dimensions added by pos_dashboard_cooperative on
+ top of point_of_sale.report.pos.order:
+
+ - time dimensions (date_day, hour_of_day, day_of_week)
+ - product category hierarchy (categ_root_id, categ_parent_id)
+ - cooperative member historic flag (is_member_at_date)
+
+ These tests use a minimal hand-built pos.session to stay independent
+ from any existing data in the database and from the heavy
+ TestPoSCommon setup machinery, which requires a clean accounting
+ setup.
+ """
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.company = cls.env.ref("base.main_company")
+
+ # Two-level category hierarchy: family -> subfamily -> leaf
+ cls.family_root = cls.env["product.category"].create(
+ {"name": "DASHBOARD-FAMILY-ROOT"}
+ )
+ cls.subfamily = cls.env["product.category"].create(
+ {"name": "DASHBOARD-SUBFAMILY", "parent_id": cls.family_root.id}
+ )
+ cls.categ_leaf = cls.env["product.category"].create(
+ {"name": "DASHBOARD-LEAF", "parent_id": cls.subfamily.id}
+ )
+
+ # Product sold at the POS in every test.
+ cls.product = cls.env["product.product"].create(
+ {
+ "name": "DASHBOARD-PRODUCT",
+ "type": "consu",
+ "available_in_pos": True,
+ "list_price": 10.0,
+ "standard_price": 6.0,
+ "categ_id": cls.categ_leaf.id,
+ }
+ )
+
+ # Share product needed by cooperator.share.line.
+ cls.share_product = cls.env["product.product"].create(
+ {
+ "name": "DASHBOARD-SHARE",
+ "is_share": True,
+ "type": "service",
+ "list_price": 25.0,
+ }
+ )
+
+ # Two partners: one cooperative member (share dated in the past)
+ # and one non-member.
+ cls.member_partner = cls.env["res.partner"].create(
+ {"name": "DASHBOARD-MEMBER"}
+ )
+ cls.non_member_partner = cls.env["res.partner"].create(
+ {"name": "DASHBOARD-NON-MEMBER"}
+ )
+ cls.env["share.line"].create(
+ {
+ "partner_id": cls.member_partner.id,
+ "share_product_id": cls.share_product.id,
+ "share_number": 1,
+ "share_unit_price": 25.0,
+ "effective_date": date.today() - timedelta(days=365),
+ "company_id": cls.company.id,
+ }
+ )
+
+ # Reuse an existing POS config to avoid bootstrapping payment
+ # methods and journals from scratch. Any installed config is
+ # acceptable since we never go through the POS UI.
+ cls.pos_config = cls.env["pos.config"].search(
+ [("company_id", "=", cls.company.id)], limit=1
+ )
+ assert cls.pos_config, "A pos.config in the main company is required."
+ if not cls.pos_config.current_session_id:
+ cls.pos_config.open_ui()
+ cls.pos_session = cls.pos_config.current_session_id
+
+ # ------------------------------------------------------------------
+ # Helpers
+ # ------------------------------------------------------------------
+ def _create_pos_order(self, partner):
+ return self.env["pos.order"].create(
+ {
+ "session_id": self.pos_session.id,
+ "partner_id": partner.id,
+ "company_id": self.company.id,
+ "lines": [
+ (
+ 0,
+ 0,
+ {
+ "name": "DASHBOARD-OL",
+ "product_id": self.product.id,
+ "price_unit": 10.0,
+ "discount": 0,
+ "qty": 1.0,
+ "price_subtotal": 10.0,
+ "price_subtotal_incl": 10.0,
+ "total_cost": 6.0,
+ },
+ )
+ ],
+ "amount_total": 10.0,
+ "amount_tax": 0.0,
+ "amount_paid": 0.0,
+ "amount_return": 0.0,
+ }
+ )
+
+ def _reports_for(self, partner):
+ return (
+ self.env["report.pos.order"]
+ .sudo()
+ .search(
+ [
+ ("product_id", "=", self.product.id),
+ ("partner_id", "=", partner.id),
+ ],
+ order="id",
+ )
+ )
+
+ # ------------------------------------------------------------------
+ # Tests
+ # ------------------------------------------------------------------
+ def test_member_flag_for_member_partner(self):
+ """Partner with a share line predating the order is flagged as
+ member."""
+ self._create_pos_order(self.member_partner)
+ reports = self._reports_for(self.member_partner)
+ self.assertTrue(reports)
+ self.assertTrue(reports[0].is_member_at_date)
+
+ def test_member_flag_for_non_member_partner(self):
+ """Partner without any share line is not flagged as member."""
+ self._create_pos_order(self.non_member_partner)
+ reports = self._reports_for(self.non_member_partner)
+ self.assertTrue(reports)
+ self.assertFalse(reports[0].is_member_at_date)
+
+ def test_member_flag_is_historic(self):
+ """Share line dated AFTER the order date does not mark the partner
+ as member for that order."""
+ future_member = self.env["res.partner"].create(
+ {"name": "DASHBOARD-FUTURE-MEMBER"}
+ )
+ self._create_pos_order(future_member)
+ self.env["share.line"].create(
+ {
+ "partner_id": future_member.id,
+ "share_product_id": self.share_product.id,
+ "share_number": 1,
+ "share_unit_price": 25.0,
+ "effective_date": date.today() + timedelta(days=30),
+ "company_id": self.company.id,
+ }
+ )
+ reports = self._reports_for(future_member)
+ self.assertTrue(reports)
+ self.assertFalse(
+ reports[0].is_member_at_date,
+ "Member flag must reflect the situation at order date.",
+ )
+
+ def test_category_hierarchy_is_resolved(self):
+ """categ_root_id and categ_parent_id reflect the product category
+ ancestors derived from parent_path."""
+ self._create_pos_order(self.member_partner)
+ reports = self._reports_for(self.member_partner)
+ self.assertTrue(reports)
+ report = reports[0]
+ self.assertEqual(report.categ_root_id, self.family_root)
+ self.assertEqual(report.categ_parent_id, self.subfamily)
+
+ def test_time_dimensions_are_populated(self):
+ """date_day matches order date; hour_of_day / day_of_week are in
+ valid ranges."""
+ self._create_pos_order(self.member_partner)
+ reports = self._reports_for(self.member_partner)
+ self.assertTrue(reports)
+ report = reports[0]
+ self.assertIsNotNone(report.date_day)
+ self.assertGreaterEqual(report.hour_of_day, 0)
+ self.assertLess(report.hour_of_day, 24)
+ self.assertGreaterEqual(report.day_of_week, 1)
+ self.assertLessEqual(report.day_of_week, 7)
+
+ def test_read_group_by_member_flag(self):
+ """Aggregations grouped by the member flag return one row per
+ member status when both kinds of partners have purchased."""
+ self._create_pos_order(self.member_partner)
+ self._create_pos_order(self.non_member_partner)
+ results = self.env["report.pos.order"].read_group(
+ [("product_id", "=", self.product.id)],
+ ["price_total", "is_member_at_date"],
+ ["is_member_at_date"],
+ )
+ statuses = {r["is_member_at_date"] for r in results}
+ self.assertIn(True, statuses)
+ self.assertIn(False, statuses)
diff --git a/pos_dashboard_cooperative/views/dashboard_tiles.xml b/pos_dashboard_cooperative/views/dashboard_tiles.xml
new file mode 100644
index 0000000..2a74920
--- /dev/null
+++ b/pos_dashboard_cooperative/views/dashboard_tiles.xml
@@ -0,0 +1,104 @@
+
+
+
+
+
+
+ Cooperative POS
+ 100
+
+
+
+
+
+ Sales last 30 days (€)
+ 10
+
+
+
+ sum
+
+ {:,.2f} €
+ [('state', 'in', ['paid', 'done', 'invoiced']),
+ ('date', '>=', (context_today - relativedelta(days=30)).strftime('%Y-%m-%d'))]
+ #2E7D32
+ #FFFFFF
+
+
+
+ Margin last 30 days (€)
+ 20
+
+
+
+ sum
+
+ {:,.2f} €
+ [('state', 'in', ['paid', 'done', 'invoiced']),
+ ('date', '>=', (context_today - relativedelta(days=30)).strftime('%Y-%m-%d'))]
+ #1565C0
+ #FFFFFF
+
+
+
+ Members sales last 30 days (€)
+ 30
+
+
+
+ sum
+
+ {:,.2f} €
+ [('state', 'in', ['paid', 'done', 'invoiced']),
+ ('is_member_at_date', '=', True),
+ ('date', '>=', (context_today - relativedelta(days=30)).strftime('%Y-%m-%d'))]
+ #EF6C00
+ #FFFFFF
+
+
+
+ Non-members sales last 30 days (€)
+ 40
+
+
+
+ sum
+
+ {:,.2f} €
+ [('state', 'in', ['paid', 'done', 'invoiced']),
+ ('is_member_at_date', '=', False),
+ ('date', '>=', (context_today - relativedelta(days=30)).strftime('%Y-%m-%d'))]
+ #6A1B9A
+ #FFFFFF
+
+
+
diff --git a/pos_dashboard_cooperative/views/menus.xml b/pos_dashboard_cooperative/views/menus.xml
new file mode 100644
index 0000000..8bafc82
--- /dev/null
+++ b/pos_dashboard_cooperative/views/menus.xml
@@ -0,0 +1,68 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pos_dashboard_cooperative/views/pos_order_report_category_views.xml b/pos_dashboard_cooperative/views/pos_order_report_category_views.xml
new file mode 100644
index 0000000..1af9acf
--- /dev/null
+++ b/pos_dashboard_cooperative/views/pos_order_report_category_views.xml
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+ Sales by Category
+ report.pos.order
+ pivot,graph,tree
+
+ {
+ 'search_default_group_categ_root': 1,
+ 'search_default_order_month': 1,
+ 'group_by_no_leaf': 1,
+ }
+
+
+ No data yet!
+
+ Add Subfamily or Product group-bys to drill down. Useful
+ to detect tractor categories.
+
Bugs are tracked on GitHub Issues.
-In case of trouble, please check there if your issue has already been reported.
-If you spotted it first, help us to smash it by providing a detailed and welcomed
-feedback.
-
Do not contact contributors directly about support or help with technical issues.
OCA, or the Odoo Community Association, is a nonprofit organization whose
-mission is to support the collaborative development of Odoo features and
-promote its widespread use.
-
This module is part of the OCA/pos project on GitHub.
+ This module allows users to choose products from the website shop in decimal quantities. This can be helpful
+ for users who need to select products in decimal quantities rather than multiples of one.
+
+
+
+
+
+
+
+
+
+
+ Configuration
+
+
+
+
+ No additional configuration required.
+
+
+
+
+
+
+
+
+
+
+
+ Features
+
+
+
+
+
+
+ Decimal Product Quantity
+
+ Allows users to select product in decimal quantities from product and cart page in Website/Shop.
+
+
+
+ Simultaneous Price Calculation
+
+ Simultaneous price calculation in the cart according to the selected decimal quantities.
+
+
+
+
+
+
+
+
+
+
+
+
Screenshots
+
+
+
+
+
+
+
Decimal Product
+ Quantity in Product Page
+
Select product
+ quantities in decimal values from Product Page.
+
+
+
+
+
Decimal Product
+ Quantity in Cart Page
+
Select product
+ quantities in decimal values from Cart Page.
+
+
+
+
+
Simultaneous price
+ calculation in cart
+
Price is calculated in
+ the cart according to the decimal product values.