add hr_timesheet_sheet_no_review, hr_timesheet_sheet_holidays, hr_timesheet_sheet_delete_cascade

This commit is contained in:
Luis 2026-09-22 10:58:36 +02:00
parent b07082f70d
commit 8b0d0bb6cc
53 changed files with 3953 additions and 0 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,308 @@
# 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,
),
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,26 @@
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.
A plain ``sheet.unlink()`` from a script or a server action raises a
``UserError`` that points to the button, so the confirmation step cannot be
skipped by accident. Code that really means to delete does
``sheet._with_delete_cascade().unlink()``.
The cascade is armed by a sentinel object rather than by a plain value in the
context, and ``_with_delete_cascade()`` is the only way to obtain it. The
context of an RPC call is controlled by the caller, so a context flag holding
a JSON value could be forged by any authenticated user and would delete a
sheet without ever showing the confirmation; a sentinel compared by identity
cannot be produced by a deserializer, and a method whose name starts with an
underscore cannot be reached remotely.
With the ``Cancel the time off`` policy, cancelling a time off request posts a
message in its chatter telling the employee that the request has been refused.
The wording talks about a refusal, not about a deleted timesheet sheet.

View file

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

View file

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

View file

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

View file

@ -0,0 +1,744 @@
# 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._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 _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)
# RF-02
def test_cascade_deletes_timesheets(self):
self._set_policy("detach")
sheet = self.sheet_other
line_a = self._new_regular_line(self.employee_other, 2.0)
line_b = self._new_regular_line(self.employee_other, 3.0)
sheet._compute_timesheet_ids()
line_ids = sheet.timesheet_ids.ids
self.assertEqual(sorted(line_ids), sorted((line_a | line_b).ids))
self._wizard(sheet, user=self.user_other).action_confirm_delete()
self.assertFalse(sheet.exists())
self.assertFalse(self.env["account.analytic.line"].browse(line_ids).exists())
def test_bare_unlink_is_refused(self):
with self.assertRaises(UserError):
self.sheet.with_user(self.user).unlink()
self.assertTrue(self.sheet.exists())
# RF-03
def test_user_cannot_delete_other_sheet(self):
other = self.sheet_other.with_user(self.user)
with self.assertRaises(AccessError):
other.check_access_rule("unlink")
with self.assertRaises(AccessError):
other.check_access_rule("write")
def test_user_can_delete_own_sheet(self):
own = self.sheet.with_user(self.user)
own.check_access_rule("unlink")
own.check_access_rule("write")
def test_internal_user_without_timesheet_group_cannot_delete_other_sheet(self):
self.assertFalse(
self.user_internal.has_group("hr_timesheet.group_hr_timesheet_user")
)
sheet = self.sheet.with_user(self.user_internal)
with self.assertRaises(AccessError):
sheet.check_access_rule("unlink")
with self.assertRaises(AccessError):
sheet.check_access_rule("write")
def test_manager_can_delete_any_sheet(self):
sheets = (self.sheet | self.sheet_other).with_user(self.user_manager)
sheets.check_access_rule("unlink")
sheets.check_access_rule("write")
# RF-04
def test_invoiced_timesheets_block_deletion(self):
self.regular_line.timesheet_invoice_id = self._invoice()
wizard = self._wizard(self.sheet)
self.assertEqual(wizard.invoiced_count, 1)
self.assertTrue(wizard.blocking_reason)
self.assertIn(self.sheet.complete_name, wizard.blocking_reason)
self.assertIn("1", wizard.blocking_reason)
with self.assertRaises(UserError):
wizard.action_confirm_delete()
with self.assertRaises(UserError):
self._cascade(self.sheet.with_user(self.user)).unlink()
self.assertTrue(self.sheet.exists())
# RF-05
def test_policy_default_is_detach(self):
company = self.env["res.company"].create({"name": "Fresh Cascade Co"})
self.assertEqual(company.timesheet_sheet_delete_leave_policy, "detach")
self.assertEqual(self.sheet._sheet_leave_policy(), "detach")
def test_policy_detach_keeps_leave(self):
self._set_policy("detach")
leave_line_ids = self.leave_lines.ids
self._wizard(self.sheet).action_confirm_delete()
self.assertFalse(self.sheet.exists())
self.assertFalse(self.regular_line.exists())
lines = self.env["account.analytic.line"].browse(leave_line_ids)
self.assertEqual(lines.exists(), lines)
self.assertFalse(lines.mapped("sheet_id"))
self.assertTrue(self.leave.exists())
self.assertEqual(self.leave.state, "validate")
def test_policy_detach_keeps_balance(self):
self._set_policy("detach")
taken, remaining = self._leave_balance(self.employee)
self._wizard(self.sheet).action_confirm_delete()
self.assertEqual(self._leave_balance(self.employee), (taken, remaining))
# RF-06
def test_cascade_cancels_leaves_and_restores_balance(self):
self._set_policy("cancel")
taken, remaining = self._leave_balance(self.employee)
self.assertEqual(taken, 3.0)
leave_line_ids = self.leave_lines.ids
leave = self.leave
self._wizard(self.sheet).action_confirm_delete()
self.assertFalse(self.sheet.exists())
self.assertFalse(
self.env["account.analytic.line"].browse(leave_line_ids).exists()
)
self.assertFalse(self.regular_line.exists())
self.assertFalse(leave.exists())
new_taken, new_remaining = self._leave_balance(self.employee)
self.assertEqual(new_taken, taken - 3.0)
self.assertEqual(new_remaining, remaining + 3.0)
# RF-07
def test_wizard_lists_leaves(self):
self._set_policy("cancel")
wizard = self._wizard(self.sheet)
self.assertEqual(wizard.leave_policy, "cancel")
self.assertEqual(wizard.leave_count, len(self.leave_lines))
self.assertEqual(wizard.leave_days, 3.0)
self.assertEqual(wizard.leave_ids, self.leave)
self.assertFalse(wizard.partial_leave_ids)
self.assertIn(self.employee.name, wizard.summary_html)
self.assertIn(self.leave_type.name, wizard.summary_html)
def test_wizard_warns_about_leaves_spanning_sheets(self):
self._set_policy("cancel")
sheet = self._span_sheet()
wizard = self._wizard(sheet, user=self.user_span)
self.assertEqual(wizard.partial_leave_ids, self.leave_span)
self.assertIn("Warning", wizard.summary_html)
self.assertIn(self.employee_span.name, wizard.summary_html)
def test_wizard_detach_policy_states_leaves_untouched(self):
self._set_policy("detach")
wizard = self._wizard(self.sheet)
self.assertEqual(wizard.leave_policy, "detach")
self.assertEqual(wizard.leave_count, len(self.leave_lines))
self.assertEqual(wizard.leave_days, 0.0)
self.assertFalse(wizard.leave_ids)
self.assertFalse(wizard.partial_leave_ids)
self.assertIn("balances do not change", wizard.summary_html)
def test_wizard_without_leaves_has_no_leave_section(self):
line = self._new_regular_line(self.employee_other, 2.0)
sheet = self.sheet_other
sheet._compute_timesheet_ids()
self.assertEqual(sheet.timesheet_ids, line)
for policy in ("detach", "cancel"):
self._set_policy(policy)
wizard = self._wizard(sheet, user=self.user_other)
self.assertEqual(wizard.leave_count, 0)
self.assertNotIn("Time off", wizard.summary_html)
def test_uncancellable_leave_blocks_deletion(self):
self._set_policy("cancel")
# A request already refused outside this module keeps its entries:
# action_refuse() would have removed them, a data fix would not.
self.leave.with_user(SUPERUSER_ID).sudo().write({"state": "refuse"})
self.assertEqual(self.sheet._sheet_leaves_to_cancel(), self.env["hr.leave"])
self.assertEqual(self.sheet._sheet_leaves_not_cancellable(), self.leave)
wizard = self._wizard(self.sheet)
self.assertTrue(wizard.blocking_reason)
self.assertIn(self.sheet.complete_name, wizard.blocking_reason)
self.assertIn("cannot be cancelled", wizard.blocking_reason)
with self.assertRaises(UserError):
wizard.action_confirm_delete()
self.env.invalidate_all()
self.assertTrue(self.sheet.exists())
self.assertEqual(self.sheet.timesheet_ids.exists(), self.sheet.timesheet_ids)
def test_multicompany_batch_summary_matches_each_policy(self):
self._set_policy("cancel")
other = self._second_company_sheet_with_leave("detach")
self.env.user.write({"company_ids": [(4, other["company"].id)]})
sheets = self.sheet | other["sheet"]
data = sheets._delete_cascade_prepare()
titles = [section["title"] for section in data["sections"]]
# Compared against the titles the model itself produces, so the test
# does not depend on the language of the user running it.
self.assertIn(other["sheet"]._delete_cascade_detach_section()["title"], titles)
self.assertIn(self.sheet._delete_cascade_cancel_section()[0]["title"], titles)
self.assertEqual(data["leave_days"], 3.0)
self.assertEqual(data["leave_count"], 5)
cancel_title = self.sheet._delete_cascade_cancel_section()[0]["title"]
cancelled = " ".join(
line
for section in data["sections"]
if section["title"] == cancel_title
for line in section["lines"]
)
self.assertIn(self.employee.name, cancelled)
self.assertNotIn(other["employee"].name, cancelled)
wizard = self._wizard(sheets, user=self.env.user)
self.assertEqual(wizard.leave_ids, self.leave)
self.assertEqual(wizard.leave_days, 3.0)
def test_public_holiday_lines_created_while_cancelling_are_deleted(self):
self._set_policy("cancel")
global_leave = (
self.env["resource.calendar.leaves"]
.with_context(allowed_company_ids=[self.company.id])
.create(
{
"name": "Public holiday",
"calendar_id": self.company.resource_calendar_id.id,
"company_id": self.company.id,
"date_from": datetime(2026, 10, 7, 0, 0, 0),
"date_to": datetime(2026, 10, 7, 23, 59, 0),
}
)
)
self.assertFalse(global_leave.timesheet_ids)
sheet_id = self.sheet.id
self._wizard(self.sheet).action_confirm_delete()
self.env.flush_all()
self.env.invalidate_all()
self.assertFalse(self.sheet.exists())
self.assertFalse(global_leave.timesheet_ids)
self.env.cr.execute(
"SELECT COUNT(*) FROM account_analytic_line WHERE sheet_id = %s",
(sheet_id,),
)
self.assertEqual(self.env.cr.fetchone()[0], 0)
# RF-08
def test_bare_unlink_of_leave_line_is_refused(self):
line = self.leave_lines[0].with_user(self.user)
with self.assertRaises(UserError):
line.unlink()
self.env.invalidate_all()
self.assertTrue(self.leave_lines[0].exists())
self.assertTrue(self.leave_lines[0].sheet_id)
def test_unlink_during_sheet_recompute_detaches(self):
line = self.leave_lines[0].with_user(self.user)
self._recompute(line).unlink()
self.env.invalidate_all()
self.assertTrue(self.leave_lines[0].exists())
self.assertFalse(self.leave_lines[0].sheet_id)
self.assertTrue(self.leave.exists())
def test_unlink_during_delete_cascade_cancel_deletes(self):
self._set_policy("cancel")
line = self.leave_lines[0]
self._cascade(line.with_user(self.user).sudo()).unlink()
self.assertFalse(line.exists())
def test_unlink_during_delete_cascade_detach_detaches(self):
self._set_policy("detach")
line = self.leave_lines[0]
self._cascade(line.with_user(self.user).sudo()).unlink()
self.env.invalidate_all()
self.assertTrue(line.exists())
self.assertFalse(line.sheet_id)
self.assertTrue(self.leave.exists())
def test_delete_cascade_flag_without_sudo_is_refused(self):
line = self.leave_lines[0].with_user(self.user)
for policy in ("detach", "cancel"):
self._set_policy(policy)
with self.assertRaises(UserError):
self._cascade(line).unlink()
self.env.invalidate_all()
self.assertTrue(self.leave_lines[0].exists())
self.assertTrue(self.leave_lines[0].sheet_id)
def test_both_flags_delete_cascade_wins(self):
self._set_policy("cancel")
line = self.leave_lines[0]
self._cascade(self._recompute(line.with_user(self.user).sudo())).unlink()
self.assertFalse(line.exists())
def test_both_flags_with_detach_policy_still_detaches(self):
self._set_policy("detach")
line = self.leave_lines[0]
self._cascade(self._recompute(line.with_user(self.user).sudo())).unlink()
self.env.invalidate_all()
self.assertTrue(line.exists())
self.assertFalse(line.sheet_id)
# RF-09
def test_no_cascade_flag_leak_after_deletion(self):
self._set_policy("cancel")
self._wizard(self.sheet).action_confirm_delete()
self.assertIsNone(self.env.context.get("sheet_delete_cascade"))
other_line = self.leave_span_lines[0].with_user(self.user_span)
with self.assertRaises(UserError):
other_line.unlink()
self.env.invalidate_all()
self.assertTrue(self.leave_span_lines[0].exists())
self.assertIsNone(self.env.context.get("sheet_delete_cascade"))
# Published hooks
def test_delete_cascade_prepare_contract(self):
data = self.sheet._delete_cascade_prepare()
self.assertEqual(
set(data),
{
"sheet_count",
"timesheet_count",
"timesheet_hours",
"invoiced_count",
"leave_count",
"leave_days",
"leave_policy",
"blocking",
"sections",
},
)
self.assertEqual(data["blocking"], [])
self.assertTrue(all("title" in s and "lines" in s for s in data["sections"]))
def test_detach_hook_matrix(self):
"""One assertion per row of the RF-08 table, on the same leave line.
The base recordset is bound to a plain employee on purpose: the class
env is superuser, so a line taken straight from the fixture would carry
env.su and the row that must fail without it would pass for the wrong
reason.
"""
line = self.leave_lines[0].with_user(self.user)
self.assertFalse(line.env.su)
line_su = line.sudo()
# Row 1: no flags.
self.assertFalse(line._detach_leave_lines_on_unlink())
# Row 2: sheet recompute, any policy.
self.assertTrue(self._recompute(line)._detach_leave_lines_on_unlink())
self._set_policy("cancel")
# Row 3: deliberate cascade with cancel deletes instead of detaching.
self.assertFalse(self._cascade(line_su)._detach_leave_lines_on_unlink())
# Row 5, cancel: the sentinel alone, without env.su, detaches nothing.
self.assertFalse(self._cascade(line)._detach_leave_lines_on_unlink())
self.assertFalse(self._cascade(line)._in_deliberate_delete_cascade())
# Row 6: the cascade wins over a concurrent recompute.
self.assertFalse(
self._cascade(self._recompute(line_su))._detach_leave_lines_on_unlink()
)
self._set_policy("detach")
# Row 4: deliberate cascade with detach keeps the line alive.
self.assertTrue(self._cascade(line_su)._detach_leave_lines_on_unlink())
# Row 5, detach: still nothing without env.su.
self.assertFalse(self._cascade(line)._detach_leave_lines_on_unlink())
self.assertFalse(self._cascade(line)._in_deliberate_delete_cascade())
def test_context_token_is_not_serializable(self):
token = self.env["account.analytic.line"]._sheet_delete_cascade_token()
with self.assertRaises(TypeError):
json.dumps(token)
def test_forged_cascade_flag_does_not_delete_sheet(self):
model = self.env["hr_timesheet.sheet"].with_user(self.user)
with self.assertRaises(UserError):
call_kw(
model,
"unlink",
[self.sheet.ids],
{"context": {"sheet_delete_cascade": True}},
)
self.env.invalidate_all()
self.assertTrue(self.sheet.exists())
self.assertEqual(self.sheet.timesheet_ids.exists(), self.sheet.timesheet_ids)
self.assertFalse(
model.with_context(sheet_delete_cascade=True)._in_delete_cascade()
)
def test_forged_cascade_flag_does_not_delete_leave_line(self):
model = self.env["account.analytic.line"].with_user(self.user)
line = self.leave_lines[0]
self._set_policy("cancel")
with self.assertRaises(UserError):
call_kw(
model,
"unlink",
[[line.id]],
{"context": {"sheet_delete_cascade": True}},
)
self.env.invalidate_all()
self.assertTrue(line.exists())
self.assertFalse(
model.sudo()
.with_context(sheet_delete_cascade=True)
._in_deliberate_delete_cascade()
)

View file

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

View file

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

View file

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

View file

@ -0,0 +1,113 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from markupsafe import Markup
from odoo import _, api, fields, models
from ..models.res_company import LEAVE_POLICY_SELECTION
class HrTimesheetSheetDeleteWizard(models.TransientModel):
_name = "hr_timesheet.sheet.delete.wizard"
_description = "Delete Timesheet Sheets"
sheet_ids = fields.Many2many(
comodel_name="hr_timesheet.sheet",
string="Timesheet Sheets",
)
sheet_count = fields.Integer(
string="Sheets",
compute="_compute_delete_cascade_data",
)
timesheet_count = fields.Integer(
string="Timesheet Entries",
compute="_compute_delete_cascade_data",
)
timesheet_hours = fields.Float(
string="Hours",
compute="_compute_delete_cascade_data",
)
invoiced_count = fields.Integer(
string="Invoiced Entries",
compute="_compute_delete_cascade_data",
)
leave_policy = fields.Selection(
selection=LEAVE_POLICY_SELECTION,
string="Time Off Policy",
compute="_compute_delete_cascade_data",
)
leave_ids = fields.Many2many(
comodel_name="hr.leave",
relation="hr_timesheet_sheet_delete_wizard_leave_rel",
string="Time Off To Cancel",
compute="_compute_delete_cascade_data",
)
leave_count = fields.Integer(
string="Time Off Entries",
compute="_compute_delete_cascade_data",
)
leave_days = fields.Float(
string="Days Restored",
compute="_compute_delete_cascade_data",
)
partial_leave_ids = fields.Many2many(
comodel_name="hr.leave",
relation="hr_timesheet_sheet_delete_wizard_partial_leave_rel",
string="Time Off Beyond The Sheet Period",
compute="_compute_delete_cascade_data",
)
blocking_reason = fields.Text(
string="Blocking Reason",
compute="_compute_delete_cascade_data",
)
summary_html = fields.Html(
string="Summary",
compute="_compute_delete_cascade_data",
sanitize=False,
)
@api.model
def default_get(self, fields_list):
res = super().default_get(fields_list)
if self.env.context.get("active_model") == "hr_timesheet.sheet":
res["sheet_ids"] = [(6, 0, self.env.context.get("active_ids") or [])]
return res
@api.depends("sheet_ids")
def _compute_delete_cascade_data(self):
for wizard in self:
sheets = wizard.sheet_ids
data = sheets._delete_cascade_prepare()
wizard.sheet_count = data["sheet_count"]
wizard.timesheet_count = data["timesheet_count"]
wizard.timesheet_hours = data["timesheet_hours"]
wizard.invoiced_count = data["invoiced_count"]
wizard.leave_policy = data["leave_policy"]
wizard.leave_count = data["leave_count"]
wizard.leave_days = data["leave_days"]
cancel_sheets = sheets._delete_cascade_cancel_sheets()
wizard.leave_ids = cancel_sheets._sheet_leaves_to_cancel().ids
wizard.partial_leave_ids = cancel_sheets._sheet_leaves_beyond_period().ids
wizard.blocking_reason = "\n".join(data["blocking"]) or False
wizard.summary_html = wizard._build_summary_html(data)
def _build_summary_html(self, data):
self.ensure_one()
if not data["sheet_count"]:
return Markup("<p>%s</p>") % _("No timesheet sheet was selected.")
parts = []
for section in data["sections"]:
parts.append(Markup("<h4>%s</h4>") % section["title"])
parts.append(
Markup("<ul>%s</ul>")
% Markup("").join(
Markup("<li>%s</li>") % line for line in section["lines"]
)
)
return Markup("").join(parts)
def action_confirm_delete(self):
self.ensure_one()
self.sheet_ids._with_delete_cascade().unlink()
return {"type": "ir.actions.act_window_close"}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,139 @@
# 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-15 07:59+0000\n"
"PO-Revision-Date: 2026-09-15 07:59+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__id
msgid "ID"
msgstr ""
#. module: hr_timesheet_sheet_no_review
#: model:ir.model.fields,field_description:hr_timesheet_sheet_no_review.field_hr_timesheet_sheet_unfreeze_wizard____last_update
msgid "Last Modified on"
msgstr ""
#. module: hr_timesheet_sheet_no_review
#: model:ir.model.fields,field_description:hr_timesheet_sheet_no_review.field_hr_timesheet_sheet_unfreeze_wizard__write_uid
msgid "Last Updated by"
msgstr ""
#. module: hr_timesheet_sheet_no_review
#: model:ir.model.fields,field_description:hr_timesheet_sheet_no_review.field_hr_timesheet_sheet_unfreeze_wizard__write_date
msgid "Last Updated on"
msgstr ""
#. module: hr_timesheet_sheet_no_review
#. odoo-python
#: code:addons/hr_timesheet_sheet_no_review/wizards/unfreeze_wizard.py:0
#, python-format
msgid "No frozen timesheet sheet was found. Nothing was changed."
msgstr ""
#. module: hr_timesheet_sheet_no_review
#: model:ir.model.fields,field_description:hr_timesheet_sheet_no_review.field_hr_timesheet_sheet_unfreeze_wizard__summary_html
msgid "Summary"
msgstr ""
#. module: hr_timesheet_sheet_no_review
#. odoo-python
#: code:addons/hr_timesheet_sheet_no_review/wizards/unfreeze_wizard.py:0
#, python-format
msgid ""
"The timesheet sheets frozen by the review workflow, and the timesheet "
"entries they keep locked, are:"
msgstr ""
#. module: hr_timesheet_sheet_no_review
#. odoo-python
#: code:addons/hr_timesheet_sheet_no_review/wizards/unfreeze_wizard.py:0
#, python-format
msgid "There is no frozen timesheet sheet. Nothing to unfreeze."
msgstr ""
#. module: hr_timesheet_sheet_no_review
#: model_terms:ir.ui.view,arch_db:hr_timesheet_sheet_no_review.hr_timesheet_sheet_unfreeze_wizard_form
msgid ""
"This is a mass operation on historical data and it cannot be\n"
" undone: the reviewer of every approved sheet is cleared. Make\n"
" sure you have a recent backup before you continue."
msgstr ""
#. module: hr_timesheet_sheet_no_review
#: model:ir.model,name:hr_timesheet_sheet_no_review.model_hr_timesheet_sheet
msgid "Timesheet Sheet"
msgstr ""
#. module: hr_timesheet_sheet_no_review
#: model_terms:ir.ui.view,arch_db:hr_timesheet_sheet_no_review.hr_timesheet_sheet_unfreeze_wizard_form
msgid "Unfreeze"
msgstr ""
#. module: hr_timesheet_sheet_no_review
#. odoo-python
#: code:addons/hr_timesheet_sheet_no_review/wizards/unfreeze_wizard.py:0
#: model:ir.actions.act_window,name:hr_timesheet_sheet_no_review.action_hr_timesheet_sheet_unfreeze_wizard
#: model:ir.ui.menu,name:hr_timesheet_sheet_no_review.menu_hr_timesheet_sheet_unfreeze
#: model_terms:ir.ui.view,arch_db:hr_timesheet_sheet_no_review.hr_timesheet_sheet_unfreeze_wizard_form
#, python-format
msgid "Unfreeze Pending Sheets"
msgstr ""
#. module: hr_timesheet_sheet_no_review
#: model:ir.model,name:hr_timesheet_sheet_no_review.model_hr_timesheet_sheet_unfreeze_wizard
msgid "Unfreeze Pending Timesheet Sheets"
msgstr ""

View file

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

View file

@ -0,0 +1,48 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
import logging
from odoo import api, models
_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):
"""Send frozen sheets back to draft. Returns the number of sheets affected.
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.
"""
sheets = self.search(self._unfreeze_domain())
if not sheets:
_logger.info("No frozen timesheet sheets to unfreeze.")
return 0
sheets.check_access_rights("write")
sheets.check_access_rule("write")
self.env.flush_all()
self.env.cr.execute(
"""
UPDATE hr_timesheet_sheet
SET state = 'draft',
reviewer_id = NULL,
write_uid = %s,
write_date = (now() at time zone 'UTC')
WHERE id IN %s
""",
(self.env.uid, tuple(sheets.ids)),
)
count = self.env.cr.rowcount
self.env.invalidate_all(flush=False)
_logger.info("Unfroze %s timesheet sheet(s).", count)
return count

View file

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

View file

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

View file

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

View file

@ -0,0 +1,22 @@
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.
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")]``.

View file

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

View file

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

View file

@ -0,0 +1,358 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
import os
from unittest.mock import patch
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 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,
)
def test_wizard_reports_pending_sheets(self):
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()
wizard = self.wizard_model.create({})
self.assertEqual(wizard.sheet_count, 0)
self.assertEqual(wizard.timesheet_count, 0)
self.assertEqual(wizard.timesheet_hours, 0.0)
self.assertIn("Nothing to unfreeze", wizard.summary_html)
def test_unfreeze_releases_sheets(self):
self.assertEqual(self.sheet.state, "confirm")
wizard = self.wizard_model.create({})
expected = wizard.sheet_count
result = wizard.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_is_idempotent(self):
self.assertTrue(self.sheet_model._unfreeze_sheets())
self.assertEqual(self.sheet_model._unfreeze_sheets(), 0)
wizard = self.wizard_model.create({})
result = wizard.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_unfrozen_lines_are_editable(self):
with self.assertRaises(UserError):
self.line.with_user(self.user).write({"unit_amount": 5.0})
self.wizard_model.create({}).action_unfreeze()
self.line.with_user(self.user).write({"unit_amount": 5.0})
self.assertEqual(self.line.unit_amount, 5.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({})
self.assertTrue(self.wizard_model.with_user(self.manager).create({}))

View file

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

View file

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

View file

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

View file

@ -0,0 +1,79 @@
# 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_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",
)
# No @api.depends: the figures come from the state of the whole database,
# not from any field of the wizard. The wizard is created anew every time
# the action is opened, so they are computed once, when the dialog is read.
def _compute_frozen_data(self):
sheet_model = self.env["hr_timesheet.sheet"]
sheets = sheet_model.search(sheet_model._unfreeze_domain())
groups = self.env["account.analytic.line"].read_group(
[("sheet_id", "in", sheets.ids)], ["unit_amount"], []
)
timesheet_count = groups[0]["__count"] if groups else 0
timesheet_hours = (groups[0]["unit_amount"] if groups else 0.0) or 0.0
for wizard in self:
wizard.sheet_count = len(sheets)
wizard.timesheet_count = timesheet_count
wizard.timesheet_hours = timesheet_hours
@api.depends("sheet_count", "timesheet_count", "timesheet_hours")
def _compute_summary_html(self):
for wizard in self:
wizard.summary_html = wizard._build_summary_html()
def _build_summary_html(self):
self.ensure_one()
if not self.sheet_count:
return Markup("<p>%s</p>") % _(
"There is no frozen timesheet sheet. Nothing to unfreeze."
)
return Markup("<p>%s</p>") % _(
"The timesheet sheets frozen by the review workflow, and the "
"timesheet entries they keep locked, are:"
)
def action_unfreeze(self):
self.ensure_one()
count = self.env["hr_timesheet.sheet"]._unfreeze_sheets()
if count:
message = _("%s timesheet sheet(s) have been set back to draft.", count)
else:
message = _("No frozen timesheet sheet was found. Nothing was changed.")
return {
"type": "ir.actions.client",
"tag": "display_notification",
"params": {
"title": _("Unfreeze Pending Sheets"),
"message": message,
"type": "success" if count else "info",
"sticky": False,
"next": {"type": "ir.actions.act_window_close"},
},
}

View file

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