add hr_timesheet_sheet_no_review, hr_timesheet_sheet_holidays, hr_timesheet_sheet_delete_cascade
This commit is contained in:
parent
b07082f70d
commit
8b0d0bb6cc
53 changed files with 3953 additions and 0 deletions
2
hr_timesheet_sheet_delete_cascade/__init__.py
Normal file
2
hr_timesheet_sheet_delete_cascade/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from . import models
|
||||
from . import wizards
|
||||
25
hr_timesheet_sheet_delete_cascade/__manifest__.py
Normal file
25
hr_timesheet_sheet_delete_cascade/__manifest__.py
Normal 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,
|
||||
}
|
||||
333
hr_timesheet_sheet_delete_cascade/i18n/es.po
Normal file
333
hr_timesheet_sheet_delete_cascade/i18n/es.po
Normal 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."
|
||||
|
|
@ -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 ""
|
||||
4
hr_timesheet_sheet_delete_cascade/models/__init__.py
Normal file
4
hr_timesheet_sheet_delete_cascade/models/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
from . import res_company
|
||||
from . import res_config_settings
|
||||
from . import account_analytic_line
|
||||
from . import hr_timesheet_sheet
|
||||
|
|
@ -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()
|
||||
308
hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py
Normal file
308
hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py
Normal 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,
|
||||
),
|
||||
}
|
||||
28
hr_timesheet_sheet_delete_cascade/models/res_company.py
Normal file
28
hr_timesheet_sheet_delete_cascade/models/res_company.py
Normal 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.",
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
24
hr_timesheet_sheet_delete_cascade/readme/CONFIGURE.rst
Normal file
24
hr_timesheet_sheet_delete_cascade/readme/CONFIGURE.rst
Normal 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.
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
* Criptomart
|
||||
|
||||
* Luis Martínez <luis@criptomart.net>
|
||||
19
hr_timesheet_sheet_delete_cascade/readme/DESCRIPTION.rst
Normal file
19
hr_timesheet_sheet_delete_cascade/readme/DESCRIPTION.rst
Normal 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.
|
||||
16
hr_timesheet_sheet_delete_cascade/readme/ROADMAP.rst
Normal file
16
hr_timesheet_sheet_delete_cascade/readme/ROADMAP.rst
Normal 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.
|
||||
26
hr_timesheet_sheet_delete_cascade/readme/USAGE.rst
Normal file
26
hr_timesheet_sheet_delete_cascade/readme/USAGE.rst
Normal 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.
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
hr_timesheet_sheet_delete_cascade/tests/__init__.py
Normal file
1
hr_timesheet_sheet_delete_cascade/tests/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import test_hr_timesheet_sheet_delete_cascade
|
||||
|
|
@ -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()
|
||||
)
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
1
hr_timesheet_sheet_delete_cascade/wizards/__init__.py
Normal file
1
hr_timesheet_sheet_delete_cascade/wizards/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import delete_sheet_wizard
|
||||
113
hr_timesheet_sheet_delete_cascade/wizards/delete_sheet_wizard.py
Normal file
113
hr_timesheet_sheet_delete_cascade/wizards/delete_sheet_wizard.py
Normal 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"}
|
||||
|
|
@ -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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue