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>