diff --git a/hr_timesheet_sheet_delete_cascade/__init__.py b/hr_timesheet_sheet_delete_cascade/__init__.py new file mode 100644 index 0000000..aee8895 --- /dev/null +++ b/hr_timesheet_sheet_delete_cascade/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import wizards diff --git a/hr_timesheet_sheet_delete_cascade/__manifest__.py b/hr_timesheet_sheet_delete_cascade/__manifest__.py new file mode 100644 index 0000000..e23d999 --- /dev/null +++ b/hr_timesheet_sheet_delete_cascade/__manifest__.py @@ -0,0 +1,25 @@ +# Copyright 2026 Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "HR Timesheet Sheet - Delete Cascade", + "version": "16.0.1.0.0", + "category": "Human Resources", + "summary": "Delete a timesheet sheet along with its timesheet lines," + " with confirmation", + "author": "Criptomart, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/timesheet", + "license": "AGPL-3", + "development_status": "Beta", + "depends": ["hr_timesheet_sheet_holidays", "sale_timesheet"], + "data": [ + "security/ir.model.access.csv", + "security/hr_timesheet_sheet_security.xml", + "wizards/delete_sheet_wizard_views.xml", + "views/hr_timesheet_sheet_views.xml", + "views/res_config_settings_views.xml", + ], + "installable": True, + "auto_install": False, + "application": False, +} diff --git a/hr_timesheet_sheet_delete_cascade/i18n/es.po b/hr_timesheet_sheet_delete_cascade/i18n/es.po new file mode 100644 index 0000000..9f206dc --- /dev/null +++ b/hr_timesheet_sheet_delete_cascade/i18n/es.po @@ -0,0 +1,333 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hr_timesheet_sheet_delete_cascade +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-09-15 08:20+0000\n" +"PO-Revision-Date: 2026-09-15 08:20+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Language: es\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. module: hr_timesheet_sheet_delete_cascade +#. odoo-python +#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0 +#, python-format +msgid "%(count)s entries, %(hours)s hours in total." +msgstr "%(count)s partes, %(hours)s horas en total." + +#. module: hr_timesheet_sheet_delete_cascade +#. odoo-python +#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0 +#, python-format +msgid "%(employee)s - %(type)s (%(date_from)s - %(date_to)s): %(days)s days" +msgstr "%(employee)s - %(type)s (%(date_from)s - %(date_to)s): %(days)s días" + +#. module: hr_timesheet_sheet_delete_cascade +#. odoo-python +#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0 +#, python-format +msgid "%s days will be restored to the employee balances." +msgstr "Se devolverán %s días al saldo de los empleados." + +#. module: hr_timesheet_sheet_delete_cascade +#. odoo-python +#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0 +#, python-format +msgid "%s time off entries will be detached from the sheet and will survive the deletion." +msgstr "%s partes de ausencia se desvincularán de la hoja y sobrevivirán al borrado." + +#. module: hr_timesheet_sheet_delete_cascade +#: model_terms:ir.ui.view,arch_db:hr_timesheet_sheet_delete_cascade.res_config_settings_view_form +msgid "" +"" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model,name:hr_timesheet_sheet_delete_cascade.model_account_analytic_line +msgid "Analytic Line" +msgstr "Línea analítica" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__blocking_reason +msgid "Blocking Reason" +msgstr "Motivo del bloqueo" + +#. module: hr_timesheet_sheet_delete_cascade +#: model_terms:ir.ui.view,arch_db:hr_timesheet_sheet_delete_cascade.hr_timesheet_sheet_delete_wizard_form +msgid "Cancel" +msgstr "Cancelar" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields.selection,name:hr_timesheet_sheet_delete_cascade.selection__hr_timesheet_sheet_delete_wizard__leave_policy__cancel +#: model:ir.model.fields.selection,name:hr_timesheet_sheet_delete_cascade.selection__res_company__timesheet_sheet_delete_leave_policy__cancel +msgid "Cancel the time off and restore the balance" +msgstr "Anular la ausencia y devolver los días al saldo" + +#. module: hr_timesheet_sheet_delete_cascade +#: model_terms:ir.ui.view,arch_db:hr_timesheet_sheet_delete_cascade.res_config_settings_view_form +msgid "" +"Choose what happens to the time off entries contained\n" +" in a timesheet sheet when the sheet is deleted. Keeping\n" +" the time off leaves the request untouched and only\n" +" detaches its entries from the sheet. Cancelling the time\n" +" off refuses and deletes the whole request and restores\n" +" the days to the employee balance, including the days\n" +" that fall outside the deleted sheet: it cannot be\n" +" undone." +msgstr "" +"Elige qué pasa con los partes de ausencia contenidos en una\n" +" hoja de servicio cuando se borra la hoja. Conservar la\n" +" ausencia la deja intacta y solo desvincula sus partes de\n" +" la hoja. Anular la ausencia rechaza y borra la ausencia\n" +" entera y devuelve los días al saldo del empleado,\n" +" incluidos los que caen fuera de la hoja borrada: no se\n" +" puede deshacer." + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model,name:hr_timesheet_sheet_delete_cascade.model_res_company +msgid "Companies" +msgstr "Compañías" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model,name:hr_timesheet_sheet_delete_cascade.model_res_config_settings +msgid "Config Settings" +msgstr "Ajustes de configuración" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__create_uid +msgid "Created by" +msgstr "Creado por" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__create_date +msgid "Created on" +msgstr "Creado el" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__leave_days +msgid "Days Restored" +msgstr "Días devueltos" + +#. module: hr_timesheet_sheet_delete_cascade +#: model_terms:ir.ui.view,arch_db:hr_timesheet_sheet_delete_cascade.hr_timesheet_sheet_form +msgid "Delete Sheet" +msgstr "Borrar hoja" + +#. module: hr_timesheet_sheet_delete_cascade +#. odoo-python +#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0 +#, python-format +msgid "Delete Timesheet Sheet" +msgstr "Borrar hoja de servicio" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.actions.act_window,name:hr_timesheet_sheet_delete_cascade.action_hr_timesheet_sheet_delete_wizard +#: model:ir.model,name:hr_timesheet_sheet_delete_cascade.model_hr_timesheet_sheet_delete_wizard +#: model_terms:ir.ui.view,arch_db:hr_timesheet_sheet_delete_cascade.hr_timesheet_sheet_delete_wizard_form +msgid "Delete Timesheet Sheets" +msgstr "Borrar hojas de servicio" + +#. module: hr_timesheet_sheet_delete_cascade +#: model_terms:ir.ui.view,arch_db:hr_timesheet_sheet_delete_cascade.hr_timesheet_sheet_delete_wizard_form +msgid "Delete permanently" +msgstr "Borrar definitivamente" + +#. module: hr_timesheet_sheet_delete_cascade +#. odoo-python +#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0 +#, python-format +msgid "Deleting a timesheet sheet also deletes its timesheet entries and cannot be undone. Use the Delete Sheet button on the sheet to review what will be deleted and confirm it." +msgstr "Borrar una hoja de servicio borra también sus partes de horas y no se puede deshacer. Usa el botón Borrar hoja de la propia hoja para revisar qué se va a borrar y confirmarlo." + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__display_name +msgid "Display Name" +msgstr "Nombre mostrado" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__timesheet_hours +msgid "Hours" +msgstr "Horas" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__id +msgid "ID" +msgstr "ID" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__invoiced_count +msgid "Invoiced Entries" +msgstr "Partes facturados" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields.selection,name:hr_timesheet_sheet_delete_cascade.selection__hr_timesheet_sheet_delete_wizard__leave_policy__detach +#: model:ir.model.fields.selection,name:hr_timesheet_sheet_delete_cascade.selection__res_company__timesheet_sheet_delete_leave_policy__detach +msgid "Keep the time off, detach its lines" +msgstr "Conservar la ausencia y desvincular sus líneas" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard____last_update +msgid "Last Modified on" +msgstr "Última modificación el" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__write_uid +msgid "Last Updated by" +msgstr "Última actualización por" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__write_date +msgid "Last Updated on" +msgstr "Última actualización el" + +#. module: hr_timesheet_sheet_delete_cascade +#. odoo-python +#: code:addons/hr_timesheet_sheet_delete_cascade/wizards/delete_sheet_wizard.py:0 +#, python-format +msgid "No timesheet sheet was selected." +msgstr "No se ha seleccionado ninguna hoja de servicio." + +#. module: hr_timesheet_sheet_delete_cascade +#. odoo-python +#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0 +#, python-format +msgid "Sheet %(sheet)s contains %(count)s invoiced timesheet entries, which can never be deleted." +msgstr "La hoja %(sheet)s contiene %(count)s partes facturados, que no se pueden borrar nunca." + +#. module: hr_timesheet_sheet_delete_cascade +#. odoo-python +#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0 +#, python-format +msgid "Sheet %(sheet)s contains entries of time off that cannot be cancelled any more: %(leaves)s. Remove them from the Time Off application first." +msgstr "La hoja %(sheet)s contiene partes de ausencias que ya no se pueden anular: %(leaves)s. Retíralos antes desde la aplicación de Ausencias." + +#. module: hr_timesheet_sheet_delete_cascade +#. odoo-python +#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0 +#, python-format +msgid "Sheet %(sheet)s is already submitted or approved and cannot be deleted." +msgstr "La hoja %(sheet)s ya está enviada o aprobada y no se puede borrar." + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__sheet_count +msgid "Sheets" +msgstr "Hojas" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__summary_html +msgid "Summary" +msgstr "Resumen" + +#. module: hr_timesheet_sheet_delete_cascade +#. odoo-python +#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0 +#, python-format +msgid "The time off requests themselves are not modified and the employee balances do not change." +msgstr "Las ausencias no se modifican y el saldo de los empleados no varía." + +#. module: hr_timesheet_sheet_delete_cascade +#: model_terms:ir.ui.view,arch_db:hr_timesheet_sheet_delete_cascade.hr_timesheet_sheet_delete_wizard_form +msgid "" +"This cannot be undone. The selected sheets and the timesheet\n" +" entries they contain will be permanently deleted." +msgstr "" +"Esto no se puede deshacer. Las hojas seleccionadas y los partes de horas\n" +" que contienen se borrarán definitivamente." + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__partial_leave_ids +msgid "Time Off Beyond The Sheet Period" +msgstr "Ausencias que exceden el periodo de la hoja" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__leave_count +msgid "Time Off Entries" +msgstr "Partes de ausencia" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__leave_policy +msgid "Time Off Policy" +msgstr "Política de ausencias" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_res_company__timesheet_sheet_delete_leave_policy +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_res_config_settings__timesheet_sheet_delete_leave_policy +msgid "Time Off Policy On Sheet Deletion" +msgstr "Política de ausencias al borrar una hoja" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__leave_ids +msgid "Time Off To Cancel" +msgstr "Ausencias que se van a anular" + +#. module: hr_timesheet_sheet_delete_cascade +#. odoo-python +#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0 +#, python-format +msgid "Time off" +msgstr "Ausencias" + +#. module: hr_timesheet_sheet_delete_cascade +#. odoo-python +#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0 +#, python-format +msgid "Time off to cancel" +msgstr "Ausencias que se van a anular" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__timesheet_count +msgid "Timesheet Entries" +msgstr "Partes de horas" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model,name:hr_timesheet_sheet_delete_cascade.model_hr_timesheet_sheet +msgid "Timesheet Sheet" +msgstr "Hoja de servicio" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__sheet_ids +msgid "Timesheet Sheets" +msgstr "Hojas de servicio" + +#. module: hr_timesheet_sheet_delete_cascade +#. odoo-python +#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0 +#, python-format +msgid "Timesheet entries to delete" +msgstr "Partes de horas que se van a borrar" + +#. module: hr_timesheet_sheet_delete_cascade +#. odoo-python +#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0 +#, python-format +msgid "Timesheet sheets to delete" +msgstr "Hojas de servicio que se van a borrar" + +#. module: hr_timesheet_sheet_delete_cascade +#. odoo-python +#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0 +#, python-format +msgid "Warning: the following time off requests also cover days outside the period of the deleted sheets, and they will be cancelled in full: %s" +msgstr "Aviso: las siguientes ausencias cubren también días fuera del periodo de las hojas borradas, y se anularán por completo: %s" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,help:hr_timesheet_sheet_delete_cascade.field_res_company__timesheet_sheet_delete_leave_policy +#: model:ir.model.fields,help:hr_timesheet_sheet_delete_cascade.field_res_config_settings__timesheet_sheet_delete_leave_policy +msgid "" +"What happens to the time off entries contained in a timesheet sheet when the sheet is deleted.\n" +"Keep the time off: the time off request is left untouched, its entries are detached from the sheet and survive, and the employee balance does not change.\n" +"Cancel the time off: the whole time off request is refused and deleted, and the days are restored to the employee balance. This cannot be undone." +msgstr "" +"Qué pasa con los partes de ausencia contenidos en una hoja de servicio cuando se borra la hoja.\n" +"Conservar la ausencia: la ausencia queda intacta, sus partes se desvinculan de la hoja y sobreviven, y el saldo del empleado no varía.\n" +"Anular la ausencia: la ausencia entera se rechaza y se borra, y los días vuelven al saldo del empleado. Esto no se puede deshacer." \ No newline at end of file diff --git a/hr_timesheet_sheet_delete_cascade/i18n/hr_timesheet_sheet_delete_cascade.pot b/hr_timesheet_sheet_delete_cascade/i18n/hr_timesheet_sheet_delete_cascade.pot new file mode 100644 index 0000000..1d2c870 --- /dev/null +++ b/hr_timesheet_sheet_delete_cascade/i18n/hr_timesheet_sheet_delete_cascade.pot @@ -0,0 +1,334 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hr_timesheet_sheet_delete_cascade +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-09-15 08:20+0000\n" +"PO-Revision-Date: 2026-09-15 08:20+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: hr_timesheet_sheet_delete_cascade +#. odoo-python +#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0 +#, python-format +msgid "%(count)s entries, %(hours)s hours in total." +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#. odoo-python +#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0 +#, python-format +msgid "%(employee)s - %(type)s (%(date_from)s - %(date_to)s): %(days)s days" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#. odoo-python +#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0 +#, python-format +msgid "%s days will be restored to the employee balances." +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#. odoo-python +#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0 +#, python-format +msgid "" +"%s time off entries will be detached from the sheet and will survive the " +"deletion." +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model_terms:ir.ui.view,arch_db:hr_timesheet_sheet_delete_cascade.res_config_settings_view_form +msgid "" +"" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model,name:hr_timesheet_sheet_delete_cascade.model_account_analytic_line +msgid "Analytic Line" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__blocking_reason +msgid "Blocking Reason" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model_terms:ir.ui.view,arch_db:hr_timesheet_sheet_delete_cascade.hr_timesheet_sheet_delete_wizard_form +msgid "Cancel" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields.selection,name:hr_timesheet_sheet_delete_cascade.selection__hr_timesheet_sheet_delete_wizard__leave_policy__cancel +#: model:ir.model.fields.selection,name:hr_timesheet_sheet_delete_cascade.selection__res_company__timesheet_sheet_delete_leave_policy__cancel +msgid "Cancel the time off and restore the balance" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model_terms:ir.ui.view,arch_db:hr_timesheet_sheet_delete_cascade.res_config_settings_view_form +msgid "" +"Choose what happens to the time off entries contained\n" +" in a timesheet sheet when the sheet is deleted. Keeping\n" +" the time off leaves the request untouched and only\n" +" detaches its entries from the sheet. Cancelling the time\n" +" off refuses and deletes the whole request and restores\n" +" the days to the employee balance, including the days\n" +" that fall outside the deleted sheet: it cannot be\n" +" undone." +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model,name:hr_timesheet_sheet_delete_cascade.model_res_company +msgid "Companies" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model,name:hr_timesheet_sheet_delete_cascade.model_res_config_settings +msgid "Config Settings" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__create_uid +msgid "Created by" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__create_date +msgid "Created on" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__leave_days +msgid "Days Restored" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model_terms:ir.ui.view,arch_db:hr_timesheet_sheet_delete_cascade.hr_timesheet_sheet_form +msgid "Delete Sheet" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#. odoo-python +#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0 +#, python-format +msgid "Delete Timesheet Sheet" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.actions.act_window,name:hr_timesheet_sheet_delete_cascade.action_hr_timesheet_sheet_delete_wizard +#: model:ir.model,name:hr_timesheet_sheet_delete_cascade.model_hr_timesheet_sheet_delete_wizard +#: model_terms:ir.ui.view,arch_db:hr_timesheet_sheet_delete_cascade.hr_timesheet_sheet_delete_wizard_form +msgid "Delete Timesheet Sheets" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model_terms:ir.ui.view,arch_db:hr_timesheet_sheet_delete_cascade.hr_timesheet_sheet_delete_wizard_form +msgid "Delete permanently" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#. odoo-python +#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0 +#, python-format +msgid "" +"Deleting a timesheet sheet also deletes its timesheet entries and cannot be " +"undone. Use the Delete Sheet button on the sheet to review what will be " +"deleted and confirm it." +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__display_name +msgid "Display Name" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__timesheet_hours +msgid "Hours" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__id +msgid "ID" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__invoiced_count +msgid "Invoiced Entries" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields.selection,name:hr_timesheet_sheet_delete_cascade.selection__hr_timesheet_sheet_delete_wizard__leave_policy__detach +#: model:ir.model.fields.selection,name:hr_timesheet_sheet_delete_cascade.selection__res_company__timesheet_sheet_delete_leave_policy__detach +msgid "Keep the time off, detach its lines" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard____last_update +msgid "Last Modified on" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__write_date +msgid "Last Updated on" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#. odoo-python +#: code:addons/hr_timesheet_sheet_delete_cascade/wizards/delete_sheet_wizard.py:0 +#, python-format +msgid "No timesheet sheet was selected." +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#. odoo-python +#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0 +#, python-format +msgid "" +"Sheet %(sheet)s contains %(count)s invoiced timesheet entries, which can " +"never be deleted." +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#. odoo-python +#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0 +#, python-format +msgid "" +"Sheet %(sheet)s contains entries of time off that cannot be cancelled any " +"more: %(leaves)s. Remove them from the Time Off application first." +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#. odoo-python +#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0 +#, python-format +msgid "" +"Sheet %(sheet)s is already submitted or approved and cannot be deleted." +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__sheet_count +msgid "Sheets" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__summary_html +msgid "Summary" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#. odoo-python +#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0 +#, python-format +msgid "" +"The time off requests themselves are not modified and the employee balances " +"do not change." +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model_terms:ir.ui.view,arch_db:hr_timesheet_sheet_delete_cascade.hr_timesheet_sheet_delete_wizard_form +msgid "" +"This cannot be undone. The selected sheets and the timesheet\n" +" entries they contain will be permanently deleted." +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__partial_leave_ids +msgid "Time Off Beyond The Sheet Period" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__leave_count +msgid "Time Off Entries" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__leave_policy +msgid "Time Off Policy" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_res_company__timesheet_sheet_delete_leave_policy +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_res_config_settings__timesheet_sheet_delete_leave_policy +msgid "Time Off Policy On Sheet Deletion" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__leave_ids +msgid "Time Off To Cancel" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#. odoo-python +#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0 +#, python-format +msgid "Time off" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#. odoo-python +#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0 +#, python-format +msgid "Time off to cancel" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__timesheet_count +msgid "Timesheet Entries" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model,name:hr_timesheet_sheet_delete_cascade.model_hr_timesheet_sheet +msgid "Timesheet Sheet" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__sheet_ids +msgid "Timesheet Sheets" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#. odoo-python +#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0 +#, python-format +msgid "Timesheet entries to delete" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#. odoo-python +#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0 +#, python-format +msgid "Timesheet sheets to delete" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#. odoo-python +#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0 +#, python-format +msgid "" +"Warning: the following time off requests also cover days outside the period " +"of the deleted sheets, and they will be cancelled in full: %s" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,help:hr_timesheet_sheet_delete_cascade.field_res_company__timesheet_sheet_delete_leave_policy +#: model:ir.model.fields,help:hr_timesheet_sheet_delete_cascade.field_res_config_settings__timesheet_sheet_delete_leave_policy +msgid "" +"What happens to the time off entries contained in a timesheet sheet when the sheet is deleted.\n" +"Keep the time off: the time off request is left untouched, its entries are detached from the sheet and survive, and the employee balance does not change.\n" +"Cancel the time off: the whole time off request is refused and deleted, and the days are restored to the employee balance. This cannot be undone." +msgstr "" diff --git a/hr_timesheet_sheet_delete_cascade/models/__init__.py b/hr_timesheet_sheet_delete_cascade/models/__init__.py new file mode 100644 index 0000000..dcdb896 --- /dev/null +++ b/hr_timesheet_sheet_delete_cascade/models/__init__.py @@ -0,0 +1,4 @@ +from . import res_company +from . import res_config_settings +from . import account_analytic_line +from . import hr_timesheet_sheet diff --git a/hr_timesheet_sheet_delete_cascade/models/account_analytic_line.py b/hr_timesheet_sheet_delete_cascade/models/account_analytic_line.py new file mode 100644 index 0000000..085b643 --- /dev/null +++ b/hr_timesheet_sheet_delete_cascade/models/account_analytic_line.py @@ -0,0 +1,62 @@ +# Copyright 2026 Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, models + +# The context of an RPC call is controlled by the client: odoo.api.call_kw +# rebuilds the environment with whatever dictionary arrives in the request, so +# a flag carrying a JSON value could be forged by any authenticated user and +# the cascade would delete a sheet without the confirmation wizard. This +# sentinel is compared by identity and cannot be produced by a deserializer, +# so it only ever reaches the consumer from inside this process. +_SHEET_DELETE_CASCADE = object() + + +class AccountAnalyticLine(models.Model): + _inherit = "account.analytic.line" + + def _sheet_delete_cascade_token(self): + """Hook for extensions""" + return _SHEET_DELETE_CASCADE + + def _in_sheet_delete_cascade(self): + return ( + self.env.context.get("sheet_delete_cascade") + is self._sheet_delete_cascade_token() + ) + + def _sheet_leave_policy(self): + """Hook for extensions""" + companies = self.sheet_id.company_id or self.company_id or self.env.company + policies = companies.mapped("timesheet_sheet_delete_leave_policy") + return "cancel" if "cancel" in policies else "detach" + + def _in_deliberate_delete_cascade(self): + """Hook for extensions + + The sentinel alone is not enough: the elevated privileges that only the + cancellation branch of the sheet grants are required as a second + barrier, so an in-process caller that merely carries the cascade + context cannot delete a time off entry on its way. + """ + return self._in_sheet_delete_cascade() and self.env.su + + def _detach_leave_lines_on_unlink(self): + if self._in_deliberate_delete_cascade(): + return self._sheet_leave_policy() != "cancel" + return super()._detach_leave_lines_on_unlink() + + @api.ondelete(at_uninstall=False) + def _unlink_except_linked_leave(self): + # Defence in depth. The cancellation branch of the cascade never gets + # here with a time off entry: project_timesheet_holidays clears + # holiday_id before unlinking the entries of a refused request + # (models/hr_holidays.py:127-134), so by then they are ordinary lines. + # This keeps the contract true for any other caller that unlinks a time + # off entry from inside a deliberate cascade. + if ( + self._in_deliberate_delete_cascade() + and self._sheet_leave_policy() == "cancel" + ): + return + return super()._unlink_except_linked_leave() diff --git a/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py b/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py new file mode 100644 index 0000000..cd2303d --- /dev/null +++ b/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py @@ -0,0 +1,312 @@ +# Copyright 2026 Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import SUPERUSER_ID, _, models +from odoo.exceptions import UserError +from odoo.tools.misc import format_date, format_duration + +REFUSABLE_LEAVE_STATES = ("draft", "confirm", "validate", "validate1") + + +class Sheet(models.Model): + _inherit = "hr_timesheet.sheet" + + def _sheet_delete_cascade_token(self): + """Hook for extensions""" + return self.env["account.analytic.line"]._sheet_delete_cascade_token() + + def _with_delete_cascade(self): + """Hook for extensions + + The deliberate way to run the cascade from code. It is a private + method on purpose: a name starting with an underscore cannot be + reached through call_kw, so the sentinel cannot be obtained remotely. + """ + token = self._sheet_delete_cascade_token() + return self.with_context(sheet_delete_cascade=token) + + def _in_delete_cascade(self): + return ( + self.env.context.get("sheet_delete_cascade") + is self._sheet_delete_cascade_token() + ) + + def _sheet_leave_policy(self): + """Hook for extensions + + The policy in force for this recordset: ``cancel`` as soon as one + company asks for it, so that a summary never understates what is about + to happen. The destructive path works sheet by sheet, where the + recordset holds a single sheet and the answer is exact. + """ + companies = self.company_id or self.env.company + policies = companies.mapped("timesheet_sheet_delete_leave_policy") + return "cancel" if "cancel" in policies else "detach" + + def _delete_cascade_cancel_sheets(self): + """Hook for extensions""" + return self.filtered(lambda sheet: sheet._sheet_leave_policy() == "cancel") + + def _sheet_leave_lines(self): + """Hook for extensions""" + return self.mapped("timesheet_ids").filtered("holiday_id") + + def _sheet_leaves(self): + """Hook for extensions + + Read in sudo: a timesheet manager entitled to delete the sheet of + another employee is not necessarily entitled to read that employee's + time off, and the confirmation wizard has to describe it. Only reached + with the ``cancel`` policy; the ``detach`` policy never reads hr.leave. + """ + return self._sheet_leave_lines().holiday_id.sudo() + + def _sheet_leaves_to_cancel(self): + """Hook for extensions""" + return self._sheet_leaves().filtered( + lambda leave: leave.state in REFUSABLE_LEAVE_STATES + ) + + def _sheet_leaves_not_cancellable(self): + """Hook for extensions""" + return self._sheet_leaves().filtered( + lambda leave: leave.state not in REFUSABLE_LEAVE_STATES + ) + + def _sheet_leaves_beyond_period(self): + """Hook for extensions""" + sheet_lines = self.mapped("timesheet_ids") + leaves = self._sheet_leaves_to_cancel() + return leaves.filtered(lambda leave: bool(leave.timesheet_ids - sheet_lines)) + + def _delete_cascade_blocking(self): + """Hook for extensions""" + reasons = [] + for sheet in self: + invoiced = sheet.timesheet_ids.filtered("timesheet_invoice_id") + if invoiced: + reasons.append( + _( + "Sheet %(sheet)s contains %(count)s invoiced timesheet" + " entries, which can never be deleted.", + sheet=sheet.complete_name, + count=len(invoiced), + ) + ) + if sheet.state in ("confirm", "done"): + reasons.append( + _( + "Sheet %(sheet)s is already submitted or approved and" + " cannot be deleted.", + sheet=sheet.complete_name, + ) + ) + if sheet._sheet_leave_policy() != "cancel": + continue + stuck = sheet._sheet_leaves_not_cancellable() + if stuck: + reasons.append( + _( + "Sheet %(sheet)s contains entries of time off that" + " cannot be cancelled any more: %(leaves)s. Remove" + " them from the Time Off application first.", + sheet=sheet.complete_name, + leaves=", ".join( + "%s (%s)" % (leave.display_name, leave.state) + for leave in stuck + ), + ) + ) + return reasons + + def _delete_cascade_check(self): + """Hook for extensions""" + reasons = self._delete_cascade_blocking() + if reasons: + raise UserError("\n".join(reasons)) + + def _delete_cascade_detach_section(self): + """Hook for extensions""" + leave_lines = self._sheet_leave_lines() + if not leave_lines: + return None + return { + "title": _("Time off"), + "lines": [ + _( + "%s time off entries will be detached from the sheet and" + " will survive the deletion.", + len(leave_lines), + ), + _( + "The time off requests themselves are not modified and the" + " employee balances do not change." + ), + ], + } + + def _delete_cascade_cancel_section(self): + """Hook for extensions""" + leaves = self._sheet_leaves_to_cancel() + if not self._sheet_leave_lines(): + return None, 0.0 + lines = [ + _( + "%(employee)s - %(type)s (%(date_from)s - %(date_to)s):" + " %(days)s days", + employee=leave.employee_id.name, + type=leave.holiday_status_id.name, + date_from=format_date(self.env, leave.date_from), + date_to=format_date(self.env, leave.date_to), + days=leave.number_of_days, + ) + for leave in leaves + ] + leave_days = sum(leaves.mapped("number_of_days")) + lines.append( + _("%s days will be restored to the employee balances.", leave_days) + ) + beyond = self._sheet_leaves_beyond_period() + if beyond: + lines.append( + _( + "Warning: the following time off requests also cover days" + " outside the period of the deleted sheets, and they will" + " be cancelled in full: %s", + ", ".join( + "%s (%s - %s)" + % ( + leave.employee_id.name, + format_date(self.env, leave.date_from), + format_date(self.env, leave.date_to), + ) + for leave in beyond + ), + ) + ) + return {"title": _("Time off to cancel"), "lines": lines}, leave_days + + def _delete_cascade_leave_sections(self): + """Hook for extensions + + Built per policy rather than per batch: a selection may span companies + that answer differently, and the summary has to describe what each + sheet is really going to do. + """ + sections = [] + cancel_sheets = self._delete_cascade_cancel_sheets() + detach_section = (self - cancel_sheets)._delete_cascade_detach_section() + if detach_section: + sections.append(detach_section) + cancel_section, leave_days = cancel_sheets._delete_cascade_cancel_section() + if cancel_section: + sections.append(cancel_section) + return sections, leave_days + + def _delete_cascade_prepare(self): + """Hook for extensions""" + lines = self.mapped("timesheet_ids") + invoiced = lines.filtered("timesheet_invoice_id") + hours = sum(lines.mapped("unit_amount")) + sections = [ + { + "title": _("Timesheet sheets to delete"), + "lines": [sheet.complete_name for sheet in self], + }, + { + "title": _("Timesheet entries to delete"), + "lines": [ + _( + "%(count)s entries, %(hours)s hours in total.", + count=len(lines), + hours=format_duration(hours), + ) + ], + }, + ] + leave_sections, leave_days = self._delete_cascade_leave_sections() + sections.extend(leave_sections) + return { + "sheet_count": len(self), + "timesheet_count": len(lines), + "timesheet_hours": hours, + "invoiced_count": len(invoiced), + "leave_count": len(self._sheet_leave_lines()), + "leave_days": leave_days, + "leave_policy": self._sheet_leave_policy(), + "blocking": self._delete_cascade_blocking(), + "sections": sections, + } + + def _delete_cascade_cancel_leaves(self): + """Hook for extensions""" + # sudo() is not enough here, and the extra elevation is deliberate. + # sudo() flips env.su but keeps the uid, and hr.leave's + # _unlink_if_correct_states gates the deletion on + # user_has_groups('hr_holidays.group_hr_holidays_user'), which reads + # the uid: without a Time Off officer behind it, deleting a time off + # that already started raises. SUPERUSER_ID is the only way to run the + # documented refuse/draft/unlink sequence as any employee. + # It stays safe because it is confined to hr.leave, it only ever + # reaches the requests found inside sheets the user is already allowed + # to delete by the record rules of this module, and it is unreachable + # unless the company policy is "cancel". + leaves = self._sheet_leaves_to_cancel().with_user(SUPERUSER_ID).sudo() + if not leaves: + return + leaves.action_refuse() + leaves.action_draft() + leaves.unlink() + + def _delete_cascade_apply(self): + """Hook for extensions""" + for sheet in self: + if sheet._sheet_leave_policy() == "cancel": + sheet._delete_cascade_cancel_leaves() + # action_refuse() calls _check_missing_global_leave_timesheets(), + # which can create public holiday entries that land in this very + # sheet. Re-read the set so they are deleted with it instead of + # being left behind pointing at a sheet that no longer exists. + sheet.invalidate_recordset(["timesheet_ids"]) + sheet.timesheet_ids.exists().unlink() + continue + lines = sheet.timesheet_ids.exists() + leave_lines = lines.filtered("holiday_id") + if leave_lines: + leave_lines.write({"sheet_id": False}) + other_lines = lines - leave_lines + if other_lines: + other_lines.unlink() + + def unlink(self): + if not self._in_delete_cascade(): + raise UserError( + _( + "Deleting a timesheet sheet also deletes its timesheet" + " entries and cannot be undone. Use the Delete Sheet" + " button on the sheet to review what will be deleted and" + " confirm it." + ) + ) + self._delete_cascade_check() + self._delete_cascade_apply() + return super().unlink() + + def action_delete_sheet(self): + return { + "type": "ir.actions.act_window", + "name": _("Delete Timesheet Sheet"), + "res_model": "hr_timesheet.sheet.delete.wizard", + "view_mode": "form", + "target": "new", + "context": dict( + self.env.context, + active_model="hr_timesheet.sheet", + active_ids=self.ids, + active_id=self.ids[0] if self.ids else False, + # The button lives in the header of the sheet form: once the + # sheet is gone that form cannot be restored, and the wizard + # has to send the user somewhere else. + sheet_delete_cascade_from_form=True, + ), + } diff --git a/hr_timesheet_sheet_delete_cascade/models/res_company.py b/hr_timesheet_sheet_delete_cascade/models/res_company.py new file mode 100644 index 0000000..b693fb7 --- /dev/null +++ b/hr_timesheet_sheet_delete_cascade/models/res_company.py @@ -0,0 +1,28 @@ +# Copyright 2026 Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + +LEAVE_POLICY_SELECTION = [ + ("detach", "Keep the time off, detach its lines"), + ("cancel", "Cancel the time off and restore the balance"), +] + + +class ResCompany(models.Model): + _inherit = "res.company" + + timesheet_sheet_delete_leave_policy = fields.Selection( + selection=LEAVE_POLICY_SELECTION, + string="Time Off Policy On Sheet Deletion", + default="detach", + required=True, + help="What happens to the time off entries contained in a timesheet" + " sheet when the sheet is deleted.\n" + "Keep the time off: the time off request is left untouched, its" + " entries are detached from the sheet and survive, and the employee" + " balance does not change.\n" + "Cancel the time off: the whole time off request is refused and" + " deleted, and the days are restored to the employee balance. This" + " cannot be undone.", + ) diff --git a/hr_timesheet_sheet_delete_cascade/models/res_config_settings.py b/hr_timesheet_sheet_delete_cascade/models/res_config_settings.py new file mode 100644 index 0000000..814dcf2 --- /dev/null +++ b/hr_timesheet_sheet_delete_cascade/models/res_config_settings.py @@ -0,0 +1,14 @@ +# Copyright 2026 Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + timesheet_sheet_delete_leave_policy = fields.Selection( + related="company_id.timesheet_sheet_delete_leave_policy", + readonly=False, + required=True, + ) diff --git a/hr_timesheet_sheet_delete_cascade/readme/CONFIGURE.rst b/hr_timesheet_sheet_delete_cascade/readme/CONFIGURE.rst new file mode 100644 index 0000000..c4cb504 --- /dev/null +++ b/hr_timesheet_sheet_delete_cascade/readme/CONFIGURE.rst @@ -0,0 +1,24 @@ +Two things are not resolved by installing the module and have to be reviewed +by hand. + +**The record rules change existing permissions.** Before this module, every +internal user could write and delete the timesheet sheet of any other +employee. The module restricts writing and deleting to the owner of the sheet +for every internal user (``base.group_user``), and lifts the restriction for +the members of *Timesheets / Administrator* +(``hr_timesheet.group_timesheet_manager``), who keep access to every sheet. +The restrictive rule is set on ``base.group_user`` on purpose: a rule set on a +timesheet group would leave any internal user outside that group with the +unrestricted access the ACL grants and no rule at all. Reading is left open so +that the *All Timesheet Sheets* menu and the reports keep working. Check with +the customer whether any flow relies on a colleague editing somebody else's +sheet before installing. + +**The time off policy has a conservative default.** In +*Settings / Timesheets / Timesheet Options*, the field *Time Off Policy On +Sheet Deletion* is per company and defaults to *Keep the time off, detach its +lines*: deleting a sheet never touches a time off request nor an employee +balance. An installation that expects the deletion of a sheet to cancel the +time off it contains has to set *Cancel the time off and restore the balance* +on purpose. That value is irreversible and cancels the whole request, even the +days that fall in other weeks. diff --git a/hr_timesheet_sheet_delete_cascade/readme/CONTRIBUTORS.rst b/hr_timesheet_sheet_delete_cascade/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000..3c75247 --- /dev/null +++ b/hr_timesheet_sheet_delete_cascade/readme/CONTRIBUTORS.rst @@ -0,0 +1,3 @@ +* Criptomart + + * Luis Martínez diff --git a/hr_timesheet_sheet_delete_cascade/readme/DESCRIPTION.rst b/hr_timesheet_sheet_delete_cascade/readme/DESCRIPTION.rst new file mode 100644 index 0000000..d17cddb --- /dev/null +++ b/hr_timesheet_sheet_delete_cascade/readme/DESCRIPTION.rst @@ -0,0 +1,19 @@ +In ``hr_timesheet_sheet``, deleting a timesheet sheet does not delete the +timesheet entries it contains: they are left behind with an empty ``Sheet`` +field, invisible from any sheet and hard to find again. + +This module makes the deletion of a sheet cascade to its timesheet entries, +behind a confirmation wizard that states beforehand how many entries and how +many hours are about to disappear. Invoiced entries are never deleted: a sheet +that contains one cannot be deleted at all. + +Because a sheet also contains the entries generated by approved time off, the +module makes that case an explicit company policy: either the time off request +is kept untouched and only its entries are detached from the sheet (the +default), or the request is cancelled and the days are restored to the +employee balance. + +Turning a harmless ``unlink()`` into a destructive one is only safe if the +record rules are in place first, so the module also adds the record rules that +``hr_timesheet.sheet`` is missing: without them any internal user can delete +the sheet of any other employee. diff --git a/hr_timesheet_sheet_delete_cascade/readme/ROADMAP.rst b/hr_timesheet_sheet_delete_cascade/readme/ROADMAP.rst new file mode 100644 index 0000000..7002136 --- /dev/null +++ b/hr_timesheet_sheet_delete_cascade/readme/ROADMAP.rst @@ -0,0 +1,16 @@ +* Public holidays generate timesheet entries carrying ``global_leave_id`` + instead of ``holiday_id``. Neither policy applies to them: they do not belong + to a single employee and there is no balance to restore. They are deleted + with the sheet like any other entry, and they are removed on their own when + the public holiday itself is deleted, through the ``ondelete='cascade'`` of + the field. + +* The ``cancel`` policy refuses, resets and deletes the time off request. An + installation that wants to keep a history of the cancelled requests would + stop at the refusal, which is already where the days are restored to the + balance. It is a known variant and it is not implemented. + +* The confirmation wizard reads the time off requests in ``sudo`` under the + ``cancel`` policy, because a timesheet manager entitled to delete the sheet + of another employee is not necessarily entitled to read that employee's time + off. diff --git a/hr_timesheet_sheet_delete_cascade/readme/USAGE.rst b/hr_timesheet_sheet_delete_cascade/readme/USAGE.rst new file mode 100644 index 0000000..0628894 --- /dev/null +++ b/hr_timesheet_sheet_delete_cascade/readme/USAGE.rst @@ -0,0 +1,33 @@ +The native trash bin is removed from the sheet form, list and kanban views. +Sheets are deleted from the **Delete Sheet** button in the header of the sheet +form, or from the **Action** menu of the list view for several sheets at once. + +Both open a confirmation wizard that lists the sheets, the number of timesheet +entries and the total number of hours that will be deleted, and a time off +section that depends on the configured policy. Nothing is deleted until +**Delete permanently** is pressed, and the button is hidden when something +blocks the deletion. + +Deleting from the form leaves the user on the list of timesheet sheets, since +the form he was on shows a sheet that no longer exists: *My Timesheet Sheets*, +or *All Timesheet Sheets* when a timesheet administrator deletes the sheet of +another employee. The deleted sheet is not kept in the breadcrumb. Deleting +several sheets from the **Action** menu of a list leaves that list where it +was, with its filters and its grouping. + +A plain ``sheet.unlink()`` from a script or a server action raises a +``UserError`` that points to the button, so the confirmation step cannot be +skipped by accident. Code that really means to delete does +``sheet._with_delete_cascade().unlink()``. + +The cascade is armed by a sentinel object rather than by a plain value in the +context, and ``_with_delete_cascade()`` is the only way to obtain it. The +context of an RPC call is controlled by the caller, so a context flag holding +a JSON value could be forged by any authenticated user and would delete a +sheet without ever showing the confirmation; a sentinel compared by identity +cannot be produced by a deserializer, and a method whose name starts with an +underscore cannot be reached remotely. + +With the ``Cancel the time off`` policy, cancelling a time off request posts a +message in its chatter telling the employee that the request has been refused. +The wording talks about a refusal, not about a deleted timesheet sheet. diff --git a/hr_timesheet_sheet_delete_cascade/security/hr_timesheet_sheet_security.xml b/hr_timesheet_sheet_delete_cascade/security/hr_timesheet_sheet_security.xml new file mode 100644 index 0000000..b4eb750 --- /dev/null +++ b/hr_timesheet_sheet_delete_cascade/security/hr_timesheet_sheet_security.xml @@ -0,0 +1,30 @@ + + + + + Timesheet sheet: only own sheets can be changed + + [('employee_id.user_id', '=', user.id)] + + + + + + + + Timesheet sheet: managers can change every sheet + + [(1, '=', 1)] + + + + + + + diff --git a/hr_timesheet_sheet_delete_cascade/security/ir.model.access.csv b/hr_timesheet_sheet_delete_cascade/security/ir.model.access.csv new file mode 100644 index 0000000..c6ea578 --- /dev/null +++ b/hr_timesheet_sheet_delete_cascade/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_hr_timesheet_sheet_delete_wizard,hr_timesheet.sheet.delete.wizard,model_hr_timesheet_sheet_delete_wizard,base.group_user,1,1,1,1 diff --git a/hr_timesheet_sheet_delete_cascade/tests/__init__.py b/hr_timesheet_sheet_delete_cascade/tests/__init__.py new file mode 100644 index 0000000..8855e3a --- /dev/null +++ b/hr_timesheet_sheet_delete_cascade/tests/__init__.py @@ -0,0 +1 @@ +from . import test_hr_timesheet_sheet_delete_cascade diff --git a/hr_timesheet_sheet_delete_cascade/tests/test_hr_timesheet_sheet_delete_cascade.py b/hr_timesheet_sheet_delete_cascade/tests/test_hr_timesheet_sheet_delete_cascade.py new file mode 100644 index 0000000..c431db9 --- /dev/null +++ b/hr_timesheet_sheet_delete_cascade/tests/test_hr_timesheet_sheet_delete_cascade.py @@ -0,0 +1,811 @@ +# Copyright 2026 Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import json +from datetime import date, datetime + +from odoo import SUPERUSER_ID +from odoo.api import call_kw +from odoo.exceptions import AccessError, UserError +from odoo.tests import tagged +from odoo.tests.common import TransactionCase + +WEEK_START = date(2026, 10, 5) +WEEK_END = date(2026, 10, 11) + + +@tagged("post_install", "-at_install") +class TestHrTimesheetSheetDeleteCascade(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env( + context=dict( + cls.env.context, + tracking_disable=True, + no_reset_password=True, + mail_create_nolog=True, + mail_notrack=True, + ) + ) + cls.company = cls.env["res.company"].create({"name": "Delete Cascade Co"}) + cls.env.user.write({"company_ids": [(4, cls.company.id)]}) + cls.company.resource_calendar_id.tz = "UTC" + cls.internal_project = cls.company.internal_project_id + cls.timeoff_task = cls.company.leave_timesheet_task_id + + cls.user = cls._new_user("tsdc_employee") + cls.user_other = cls._new_user("tsdc_other") + cls.user_span = cls._new_user("tsdc_span") + cls.user_manager = cls._new_user( + "tsdc_manager", group="hr_timesheet.group_timesheet_manager" + ) + cls.user_internal = cls._new_internal_user("tsdc_internal") + cls.employee = cls._new_employee("Cascade Employee", cls.user) + cls.employee_other = cls._new_employee("Other Employee", cls.user_other) + cls.employee_span = cls._new_employee("Span Employee", cls.user_span) + cls.employee_manager = cls._new_employee("Cascade Manager", cls.user_manager) + + cls.project = cls.env["project.project"].create( + { + "name": "Delete Cascade Project", + "company_id": cls.company.id, + "allow_timesheets": True, + "is_timesheet_task_required": False, + } + ) + cls.leave_type = cls.env["hr.leave.type"].create( + { + "name": "Paid Time Off (cascade test)", + "company_id": cls.company.id, + "requires_allocation": "yes", + "allocation_validation_type": "no", + "leave_validation_type": "no_validation", + "timesheet_generate": True, + "timesheet_project_id": cls.internal_project.id, + "timesheet_task_id": cls.timeoff_task.id, + } + ) + cls._allocate(cls.employee) + cls._allocate(cls.employee_span) + + cls.leave = cls._request_leave( + cls.employee, + datetime(2026, 10, 6, 6, 0, 0), + datetime(2026, 10, 8, 18, 0, 0), + ) + cls.leave_lines = cls.leave.sudo().timesheet_ids + cls.leave_span = cls._request_leave( + cls.employee_span, + datetime(2026, 10, 8, 6, 0, 0), + datetime(2026, 10, 13, 18, 0, 0), + ) + cls.leave_span_lines = cls.leave_span.sudo().timesheet_ids + + cls.sheet = cls._new_sheet(cls.employee) + cls.regular_line = cls._new_regular_line(cls.employee, 4.0) + cls.sheet._compute_timesheet_ids() + cls.sheet_other = cls._new_sheet(cls.employee_other) + + # Fixture helpers + + @classmethod + def _new_user(cls, login, group="hr_timesheet.group_hr_timesheet_user"): + return ( + cls.env["res.users"] + .with_context(no_reset_password=True, default_role_line_ids=[]) + .create( + { + "name": login, + "login": login, + "email": "%s@example.org" % login, + "company_id": cls.company.id, + "company_ids": [(6, 0, [cls.company.id])], + "groups_id": [ + ( + 6, + 0, + [ + cls.env.ref(group).id, + cls.env.ref("project.group_project_user").id, + # Third party add-ons read project_id.stage_id + # when a timesheet line is written, and that + # field is protected by this group. + cls.env.ref("project.group_project_stages").id, + ], + ) + ], + } + ) + ) + + @classmethod + def _new_internal_user(cls, login): + return ( + cls.env["res.users"] + .with_context(no_reset_password=True, default_role_line_ids=[]) + .create( + { + "name": login, + "login": login, + "email": "%s@example.org" % login, + "company_id": cls.company.id, + "company_ids": [(6, 0, [cls.company.id])], + "groups_id": [(6, 0, [cls.env.ref("base.group_user").id])], + } + ) + ) + + @classmethod + def _new_employee(cls, name, user): + return cls.env["hr.employee"].create( + {"name": name, "user_id": user.id, "company_id": cls.company.id} + ) + + @classmethod + def _allocate(cls, employee): + allocation = cls.env["hr.leave.allocation"].create( + { + "name": "Allocation for %s" % employee.name, + "holiday_status_id": cls.leave_type.id, + "employee_id": employee.id, + "number_of_days": 25, + "date_from": date(2026, 1, 1), + } + ) + if allocation.state != "validate": + allocation.action_validate() + return allocation + + @classmethod + def _request_leave(cls, employee, date_from, date_to): + return cls.env["hr.leave"].create( + { + "name": "Time off for %s" % employee.name, + "holiday_status_id": cls.leave_type.id, + "employee_id": employee.id, + "date_from": date_from, + "date_to": date_to, + } + ) + + @classmethod + def _new_sheet(cls, employee): + return cls.env["hr_timesheet.sheet"].create( + { + "employee_id": employee.id, + "company_id": cls.company.id, + "date_start": WEEK_START, + "date_end": WEEK_END, + } + ) + + @classmethod + def _new_regular_line(cls, employee, hours): + return cls.env["account.analytic.line"].create( + { + "name": "Regular work", + "project_id": cls.project.id, + "employee_id": employee.id, + "date": WEEK_START, + "unit_amount": hours, + } + ) + + # Test helpers + + def _set_policy(self, policy): + self.company.timesheet_sheet_delete_leave_policy = policy + + def _recompute(self, records): + token = self.env["account.analytic.line"]._sheet_recompute_token() + return records.with_context(sheet_recompute=token) + + def _cascade(self, records): + token = self.env["account.analytic.line"]._sheet_delete_cascade_token() + return records.with_context(sheet_delete_cascade=token) + + def _wizard(self, sheets, user=None): + return ( + self.env["hr_timesheet.sheet.delete.wizard"] + .with_user(user or self.user) + .with_context( + active_model="hr_timesheet.sheet", active_ids=sheets.ids + ) + .create({}) + ) + + def _wizard_from_form(self, sheets, user=None): + user = user or self.user + action = sheets.with_user(user).action_delete_sheet() + return ( + self.env["hr_timesheet.sheet.delete.wizard"] + .with_user(user) + .with_context(**action["context"]) + .create({}) + ) + + def _leave_balance(self, employee): + leave_type = self.leave_type.with_context(employee_id=employee.id) + leave_type.invalidate_recordset() + return leave_type.leaves_taken, leave_type.virtual_remaining_leaves + + def _span_sheet(self): + sheet = self._new_sheet(self.employee_span) + sheet._compute_timesheet_ids() + return sheet + + def _second_company_sheet_with_leave(self, policy): + company = self.env["res.company"].create({"name": "Cascade Co %s" % policy}) + company.resource_calendar_id.tz = "UTC" + company.timesheet_sheet_delete_leave_policy = policy + user = ( + self.env["res.users"] + .with_context(no_reset_password=True, default_role_line_ids=[]) + .create( + { + "name": "tsdc_%s" % policy, + "login": "tsdc_%s" % policy, + "email": "tsdc_%s@example.org" % policy, + "company_id": company.id, + "company_ids": [(6, 0, [company.id])], + "groups_id": [ + ( + 6, + 0, + [ + self.env.ref( + "hr_timesheet.group_hr_timesheet_user" + ).id, + self.env.ref("project.group_project_user").id, + self.env.ref("project.group_project_stages").id, + ], + ) + ], + } + ) + ) + employee = self.env["hr.employee"].create( + { + "name": "Employee %s" % policy, + "user_id": user.id, + "company_id": company.id, + } + ) + leave_type = self.env["hr.leave.type"].create( + { + "name": "Paid Time Off (%s)" % policy, + "company_id": company.id, + "requires_allocation": "yes", + "allocation_validation_type": "no", + "leave_validation_type": "no_validation", + "timesheet_generate": True, + "timesheet_project_id": company.internal_project_id.id, + "timesheet_task_id": company.leave_timesheet_task_id.id, + } + ) + allocation = self.env["hr.leave.allocation"].create( + { + "name": "Allocation %s" % policy, + "holiday_status_id": leave_type.id, + "employee_id": employee.id, + "number_of_days": 25, + "date_from": date(2026, 1, 1), + } + ) + if allocation.state != "validate": + allocation.action_validate() + leave = self.env["hr.leave"].create( + { + "name": "Time off %s" % policy, + "holiday_status_id": leave_type.id, + "employee_id": employee.id, + "date_from": datetime(2026, 10, 6, 6, 0, 0), + "date_to": datetime(2026, 10, 7, 18, 0, 0), + } + ) + sheet = self.env["hr_timesheet.sheet"].create( + { + "employee_id": employee.id, + "company_id": company.id, + "date_start": WEEK_START, + "date_end": WEEK_END, + } + ) + sheet._compute_timesheet_ids() + return { + "company": company, + "user": user, + "employee": employee, + "leave": leave, + "sheet": sheet, + } + + def _invoice(self): + journal = self.env["account.journal"].create( + { + "name": "Cascade Test Journal", + "code": "CTJ", + "type": "general", + "company_id": self.company.id, + } + ) + return self.env["account.move"].create( + { + "move_type": "entry", + "journal_id": journal.id, + "company_id": self.company.id, + } + ) + + # Fixture sanity + + def test_fixture(self): + self.assertEqual(len(self.leave_lines), 3) + self.assertEqual(self.leave.state, "validate") + self.assertEqual(len(self.leave_span_lines), 4) + self.assertEqual( + self.sheet.timesheet_ids, self.leave_lines | self.regular_line + ) + + # RF-01 + + def test_wizard_preview_does_not_delete(self): + wizard = self._wizard(self.sheet) + self.assertEqual(wizard.sheet_count, 1) + self.assertEqual(wizard.timesheet_count, len(self.sheet.timesheet_ids)) + self.assertEqual( + wizard.timesheet_hours, + sum(self.sheet.timesheet_ids.mapped("unit_amount")), + ) + self.assertIn(self.sheet.complete_name, wizard.summary_html) + self.assertTrue(self.sheet.exists()) + self.assertEqual(self.sheet.timesheet_ids.exists(), self.sheet.timesheet_ids) + + def test_wizard_action_opens_on_selected_sheets(self): + action = self.sheet.with_user(self.user).action_delete_sheet() + self.assertEqual(action["res_model"], "hr_timesheet.sheet.delete.wizard") + self.assertEqual(action["context"]["active_ids"], self.sheet.ids) + + # Navigation once the sheet the user was looking at is gone + + def test_delete_from_form_returns_to_the_sheet_list(self): + wizard = self._wizard_from_form(self.sheet) + action = wizard.action_confirm_delete() + self.assertEqual(action["type"], "ir.actions.act_window") + self.assertEqual(action["res_model"], "hr_timesheet.sheet") + self.assertEqual(action["target"], "main") + self.assertIn("tree", action["view_mode"]) + self.assertFalse(self.sheet.exists()) + + def test_delete_from_form_returns_own_sheets_to_an_employee(self): + action = self._wizard_from_form(self.sheet).action_confirm_delete() + self.assertEqual( + action["xml_id"], + "hr_timesheet_sheet.act_hr_timesheet_sheet_my_timesheets", + ) + + def test_delete_from_form_returns_own_sheets_to_a_manager(self): + manager_sheet = self._new_sheet(self.employee_manager) + action = self._wizard_from_form( + manager_sheet, user=self.user_manager + ).action_confirm_delete() + self.assertEqual( + action["xml_id"], + "hr_timesheet_sheet.act_hr_timesheet_sheet_my_timesheets", + ) + + def test_delete_of_another_employee_sheet_returns_all_sheets(self): + action = self._wizard_from_form( + self.sheet_other, user=self.user_manager + ).action_confirm_delete() + self.assertEqual( + action["xml_id"], + "hr_timesheet_sheet.act_hr_timesheet_sheet_all_timesheets", + ) + self.assertFalse(self.sheet_other.exists()) + + def test_delete_from_list_stays_on_the_list(self): + action = self._wizard(self.sheet).action_confirm_delete() + self.assertEqual(action, {"type": "ir.actions.act_window_close"}) + self.assertFalse(self.sheet.exists()) + + def test_wizard_is_not_bound_to_the_sheet_form(self): + action = self.env.ref( + "hr_timesheet_sheet_delete_cascade" + ".action_hr_timesheet_sheet_delete_wizard" + ) + self.assertNotIn("form", action.binding_view_types) + + def test_blocked_deletion_navigates_nowhere(self): + self.regular_line.timesheet_invoice_id = self._invoice() + wizard = self._wizard_from_form(self.sheet) + with self.assertRaises(UserError): + wizard.action_confirm_delete() + self.assertTrue(self.sheet.exists()) + + # RF-02 + + def test_cascade_deletes_timesheets(self): + self._set_policy("detach") + sheet = self.sheet_other + line_a = self._new_regular_line(self.employee_other, 2.0) + line_b = self._new_regular_line(self.employee_other, 3.0) + sheet._compute_timesheet_ids() + line_ids = sheet.timesheet_ids.ids + self.assertEqual(sorted(line_ids), sorted((line_a | line_b).ids)) + self._wizard(sheet, user=self.user_other).action_confirm_delete() + self.assertFalse(sheet.exists()) + self.assertFalse(self.env["account.analytic.line"].browse(line_ids).exists()) + + def test_bare_unlink_is_refused(self): + with self.assertRaises(UserError): + self.sheet.with_user(self.user).unlink() + self.assertTrue(self.sheet.exists()) + + # RF-03 + + def test_user_cannot_delete_other_sheet(self): + other = self.sheet_other.with_user(self.user) + with self.assertRaises(AccessError): + other.check_access_rule("unlink") + with self.assertRaises(AccessError): + other.check_access_rule("write") + + def test_user_can_delete_own_sheet(self): + own = self.sheet.with_user(self.user) + own.check_access_rule("unlink") + own.check_access_rule("write") + + def test_internal_user_without_timesheet_group_cannot_delete_other_sheet(self): + self.assertFalse( + self.user_internal.has_group("hr_timesheet.group_hr_timesheet_user") + ) + sheet = self.sheet.with_user(self.user_internal) + with self.assertRaises(AccessError): + sheet.check_access_rule("unlink") + with self.assertRaises(AccessError): + sheet.check_access_rule("write") + + def test_manager_can_delete_any_sheet(self): + sheets = (self.sheet | self.sheet_other).with_user(self.user_manager) + sheets.check_access_rule("unlink") + sheets.check_access_rule("write") + + # RF-04 + + def test_invoiced_timesheets_block_deletion(self): + self.regular_line.timesheet_invoice_id = self._invoice() + wizard = self._wizard(self.sheet) + self.assertEqual(wizard.invoiced_count, 1) + self.assertTrue(wizard.blocking_reason) + self.assertIn(self.sheet.complete_name, wizard.blocking_reason) + self.assertIn("1", wizard.blocking_reason) + with self.assertRaises(UserError): + wizard.action_confirm_delete() + with self.assertRaises(UserError): + self._cascade(self.sheet.with_user(self.user)).unlink() + self.assertTrue(self.sheet.exists()) + + # RF-05 + + def test_policy_default_is_detach(self): + company = self.env["res.company"].create({"name": "Fresh Cascade Co"}) + self.assertEqual(company.timesheet_sheet_delete_leave_policy, "detach") + self.assertEqual(self.sheet._sheet_leave_policy(), "detach") + + def test_policy_detach_keeps_leave(self): + self._set_policy("detach") + leave_line_ids = self.leave_lines.ids + self._wizard(self.sheet).action_confirm_delete() + self.assertFalse(self.sheet.exists()) + self.assertFalse(self.regular_line.exists()) + lines = self.env["account.analytic.line"].browse(leave_line_ids) + self.assertEqual(lines.exists(), lines) + self.assertFalse(lines.mapped("sheet_id")) + self.assertTrue(self.leave.exists()) + self.assertEqual(self.leave.state, "validate") + + def test_policy_detach_keeps_balance(self): + self._set_policy("detach") + taken, remaining = self._leave_balance(self.employee) + self._wizard(self.sheet).action_confirm_delete() + self.assertEqual(self._leave_balance(self.employee), (taken, remaining)) + + # RF-06 + + def test_cascade_cancels_leaves_and_restores_balance(self): + self._set_policy("cancel") + taken, remaining = self._leave_balance(self.employee) + self.assertEqual(taken, 3.0) + leave_line_ids = self.leave_lines.ids + leave = self.leave + self._wizard(self.sheet).action_confirm_delete() + self.assertFalse(self.sheet.exists()) + self.assertFalse( + self.env["account.analytic.line"].browse(leave_line_ids).exists() + ) + self.assertFalse(self.regular_line.exists()) + self.assertFalse(leave.exists()) + new_taken, new_remaining = self._leave_balance(self.employee) + self.assertEqual(new_taken, taken - 3.0) + self.assertEqual(new_remaining, remaining + 3.0) + + # RF-07 + + def test_wizard_lists_leaves(self): + self._set_policy("cancel") + wizard = self._wizard(self.sheet) + self.assertEqual(wizard.leave_policy, "cancel") + self.assertEqual(wizard.leave_count, len(self.leave_lines)) + self.assertEqual(wizard.leave_days, 3.0) + self.assertEqual(wizard.leave_ids, self.leave) + self.assertFalse(wizard.partial_leave_ids) + self.assertIn(self.employee.name, wizard.summary_html) + self.assertIn(self.leave_type.name, wizard.summary_html) + + def test_wizard_warns_about_leaves_spanning_sheets(self): + self._set_policy("cancel") + sheet = self._span_sheet() + wizard = self._wizard(sheet, user=self.user_span) + self.assertEqual(wizard.partial_leave_ids, self.leave_span) + self.assertIn("Warning", wizard.summary_html) + self.assertIn(self.employee_span.name, wizard.summary_html) + + def test_wizard_detach_policy_states_leaves_untouched(self): + self._set_policy("detach") + wizard = self._wizard(self.sheet) + self.assertEqual(wizard.leave_policy, "detach") + self.assertEqual(wizard.leave_count, len(self.leave_lines)) + self.assertEqual(wizard.leave_days, 0.0) + self.assertFalse(wizard.leave_ids) + self.assertFalse(wizard.partial_leave_ids) + self.assertIn("balances do not change", wizard.summary_html) + + def test_wizard_without_leaves_has_no_leave_section(self): + line = self._new_regular_line(self.employee_other, 2.0) + sheet = self.sheet_other + sheet._compute_timesheet_ids() + self.assertEqual(sheet.timesheet_ids, line) + for policy in ("detach", "cancel"): + self._set_policy(policy) + wizard = self._wizard(sheet, user=self.user_other) + self.assertEqual(wizard.leave_count, 0) + self.assertNotIn("Time off", wizard.summary_html) + + def test_uncancellable_leave_blocks_deletion(self): + self._set_policy("cancel") + # A request already refused outside this module keeps its entries: + # action_refuse() would have removed them, a data fix would not. + self.leave.with_user(SUPERUSER_ID).sudo().write({"state": "refuse"}) + self.assertEqual(self.sheet._sheet_leaves_to_cancel(), self.env["hr.leave"]) + self.assertEqual(self.sheet._sheet_leaves_not_cancellable(), self.leave) + wizard = self._wizard(self.sheet) + self.assertTrue(wizard.blocking_reason) + self.assertIn(self.sheet.complete_name, wizard.blocking_reason) + self.assertIn("cannot be cancelled", wizard.blocking_reason) + with self.assertRaises(UserError): + wizard.action_confirm_delete() + self.env.invalidate_all() + self.assertTrue(self.sheet.exists()) + self.assertEqual(self.sheet.timesheet_ids.exists(), self.sheet.timesheet_ids) + + def test_multicompany_batch_summary_matches_each_policy(self): + self._set_policy("cancel") + other = self._second_company_sheet_with_leave("detach") + self.env.user.write({"company_ids": [(4, other["company"].id)]}) + sheets = self.sheet | other["sheet"] + data = sheets._delete_cascade_prepare() + titles = [section["title"] for section in data["sections"]] + # Compared against the titles the model itself produces, so the test + # does not depend on the language of the user running it. + self.assertIn(other["sheet"]._delete_cascade_detach_section()["title"], titles) + self.assertIn(self.sheet._delete_cascade_cancel_section()[0]["title"], titles) + self.assertEqual(data["leave_days"], 3.0) + self.assertEqual(data["leave_count"], 5) + cancel_title = self.sheet._delete_cascade_cancel_section()[0]["title"] + cancelled = " ".join( + line + for section in data["sections"] + if section["title"] == cancel_title + for line in section["lines"] + ) + self.assertIn(self.employee.name, cancelled) + self.assertNotIn(other["employee"].name, cancelled) + wizard = self._wizard(sheets, user=self.env.user) + self.assertEqual(wizard.leave_ids, self.leave) + self.assertEqual(wizard.leave_days, 3.0) + + def test_public_holiday_lines_created_while_cancelling_are_deleted(self): + self._set_policy("cancel") + global_leave = ( + self.env["resource.calendar.leaves"] + .with_context(allowed_company_ids=[self.company.id]) + .create( + { + "name": "Public holiday", + "calendar_id": self.company.resource_calendar_id.id, + "company_id": self.company.id, + "date_from": datetime(2026, 10, 7, 0, 0, 0), + "date_to": datetime(2026, 10, 7, 23, 59, 0), + } + ) + ) + self.assertFalse(global_leave.timesheet_ids) + sheet_id = self.sheet.id + self._wizard(self.sheet).action_confirm_delete() + self.env.flush_all() + self.env.invalidate_all() + self.assertFalse(self.sheet.exists()) + self.assertFalse(global_leave.timesheet_ids) + self.env.cr.execute( + "SELECT COUNT(*) FROM account_analytic_line WHERE sheet_id = %s", + (sheet_id,), + ) + self.assertEqual(self.env.cr.fetchone()[0], 0) + + # RF-08 + + def test_bare_unlink_of_leave_line_is_refused(self): + line = self.leave_lines[0].with_user(self.user) + with self.assertRaises(UserError): + line.unlink() + self.env.invalidate_all() + self.assertTrue(self.leave_lines[0].exists()) + self.assertTrue(self.leave_lines[0].sheet_id) + + def test_unlink_during_sheet_recompute_detaches(self): + line = self.leave_lines[0].with_user(self.user) + self._recompute(line).unlink() + self.env.invalidate_all() + self.assertTrue(self.leave_lines[0].exists()) + self.assertFalse(self.leave_lines[0].sheet_id) + self.assertTrue(self.leave.exists()) + + def test_unlink_during_delete_cascade_cancel_deletes(self): + self._set_policy("cancel") + line = self.leave_lines[0] + self._cascade(line.with_user(self.user).sudo()).unlink() + self.assertFalse(line.exists()) + + def test_unlink_during_delete_cascade_detach_detaches(self): + self._set_policy("detach") + line = self.leave_lines[0] + self._cascade(line.with_user(self.user).sudo()).unlink() + self.env.invalidate_all() + self.assertTrue(line.exists()) + self.assertFalse(line.sheet_id) + self.assertTrue(self.leave.exists()) + + def test_delete_cascade_flag_without_sudo_is_refused(self): + line = self.leave_lines[0].with_user(self.user) + for policy in ("detach", "cancel"): + self._set_policy(policy) + with self.assertRaises(UserError): + self._cascade(line).unlink() + self.env.invalidate_all() + self.assertTrue(self.leave_lines[0].exists()) + self.assertTrue(self.leave_lines[0].sheet_id) + + def test_both_flags_delete_cascade_wins(self): + self._set_policy("cancel") + line = self.leave_lines[0] + self._cascade(self._recompute(line.with_user(self.user).sudo())).unlink() + self.assertFalse(line.exists()) + + def test_both_flags_with_detach_policy_still_detaches(self): + self._set_policy("detach") + line = self.leave_lines[0] + self._cascade(self._recompute(line.with_user(self.user).sudo())).unlink() + self.env.invalidate_all() + self.assertTrue(line.exists()) + self.assertFalse(line.sheet_id) + + # RF-09 + + def test_no_cascade_flag_leak_after_deletion(self): + self._set_policy("cancel") + self._wizard(self.sheet).action_confirm_delete() + self.assertIsNone(self.env.context.get("sheet_delete_cascade")) + other_line = self.leave_span_lines[0].with_user(self.user_span) + with self.assertRaises(UserError): + other_line.unlink() + self.env.invalidate_all() + self.assertTrue(self.leave_span_lines[0].exists()) + self.assertIsNone(self.env.context.get("sheet_delete_cascade")) + + # Published hooks + + def test_delete_cascade_prepare_contract(self): + data = self.sheet._delete_cascade_prepare() + self.assertEqual( + set(data), + { + "sheet_count", + "timesheet_count", + "timesheet_hours", + "invoiced_count", + "leave_count", + "leave_days", + "leave_policy", + "blocking", + "sections", + }, + ) + self.assertEqual(data["blocking"], []) + self.assertTrue(all("title" in s and "lines" in s for s in data["sections"])) + + def test_detach_hook_matrix(self): + """One assertion per row of the RF-08 table, on the same leave line. + + The base recordset is bound to a plain employee on purpose: the class + env is superuser, so a line taken straight from the fixture would carry + env.su and the row that must fail without it would pass for the wrong + reason. + """ + line = self.leave_lines[0].with_user(self.user) + self.assertFalse(line.env.su) + line_su = line.sudo() + + # Row 1: no flags. + self.assertFalse(line._detach_leave_lines_on_unlink()) + # Row 2: sheet recompute, any policy. + self.assertTrue(self._recompute(line)._detach_leave_lines_on_unlink()) + + self._set_policy("cancel") + # Row 3: deliberate cascade with cancel deletes instead of detaching. + self.assertFalse(self._cascade(line_su)._detach_leave_lines_on_unlink()) + # Row 5, cancel: the sentinel alone, without env.su, detaches nothing. + self.assertFalse(self._cascade(line)._detach_leave_lines_on_unlink()) + self.assertFalse(self._cascade(line)._in_deliberate_delete_cascade()) + # Row 6: the cascade wins over a concurrent recompute. + self.assertFalse( + self._cascade(self._recompute(line_su))._detach_leave_lines_on_unlink() + ) + + self._set_policy("detach") + # Row 4: deliberate cascade with detach keeps the line alive. + self.assertTrue(self._cascade(line_su)._detach_leave_lines_on_unlink()) + # Row 5, detach: still nothing without env.su. + self.assertFalse(self._cascade(line)._detach_leave_lines_on_unlink()) + self.assertFalse(self._cascade(line)._in_deliberate_delete_cascade()) + + def test_context_token_is_not_serializable(self): + token = self.env["account.analytic.line"]._sheet_delete_cascade_token() + with self.assertRaises(TypeError): + json.dumps(token) + + def test_forged_cascade_flag_does_not_delete_sheet(self): + model = self.env["hr_timesheet.sheet"].with_user(self.user) + with self.assertRaises(UserError): + call_kw( + model, + "unlink", + [self.sheet.ids], + {"context": {"sheet_delete_cascade": True}}, + ) + self.env.invalidate_all() + self.assertTrue(self.sheet.exists()) + self.assertEqual(self.sheet.timesheet_ids.exists(), self.sheet.timesheet_ids) + self.assertFalse( + model.with_context(sheet_delete_cascade=True)._in_delete_cascade() + ) + + def test_forged_cascade_flag_does_not_delete_leave_line(self): + model = self.env["account.analytic.line"].with_user(self.user) + line = self.leave_lines[0] + self._set_policy("cancel") + with self.assertRaises(UserError): + call_kw( + model, + "unlink", + [[line.id]], + {"context": {"sheet_delete_cascade": True}}, + ) + self.env.invalidate_all() + self.assertTrue(line.exists()) + self.assertFalse( + model.sudo() + .with_context(sheet_delete_cascade=True) + ._in_deliberate_delete_cascade() + ) diff --git a/hr_timesheet_sheet_delete_cascade/views/hr_timesheet_sheet_views.xml b/hr_timesheet_sheet_delete_cascade/views/hr_timesheet_sheet_views.xml new file mode 100644 index 0000000..76f947b --- /dev/null +++ b/hr_timesheet_sheet_delete_cascade/views/hr_timesheet_sheet_views.xml @@ -0,0 +1,62 @@ + + + + + hr_timesheet.sheet.form.delete.cascade + hr_timesheet.sheet + + +
+ 0 +
+ + ' + data.warning + ''); + } + else { + cart_alert.html(' ' + data.warning); + } + $input.val(data.quantity); + } + }); +}, + +_onChangeCartQuantity: function (ev) { + var $input = $(ev.currentTarget); + if ($input.data('update_change')) { + return; + } + // Replace comma with dot to support decimal separator + var inputValue = ($input.val() || 0).toString().replace(',', '.'); + var value = parseFloat(inputValue, 10); + if (isNaN(value)) { + value = 1; + } + + // Check if decimal quantities are allowed for this product + var allowDecimal = $input.data('allow-decimal'); + // Convert to boolean - check for string 'true' or boolean true + var allowDecimals = (allowDecimal === true || allowDecimal === 'true'); + + if (!allowDecimals) { + // Round to nearest integer for products with unit-type UoM + value = Math.round(value); + // Update the input to show the rounded value + $input.val(value); + } + + var $dom = $input.closest('tr'); + // var default_price = parseFloat($dom.find('.text-danger > span.oe_currency_value').text()); + var $dom_optional = $dom.nextUntil(':not(.optional_product.info)'); + var line_id = parseInt($input.data('line-id'), 10); + var productIDs = [parseInt($input.data('product-id'), 10)]; + this._changeCartQuantity($input, value, $dom_optional, line_id, productIDs); + } +}); + +}); diff --git a/website_decimal_quantity/static/src/js/website_sale_utils.js b/website_decimal_quantity/static/src/js/website_sale_utils.js new file mode 100644 index 0000000..d1a82f9 --- /dev/null +++ b/website_decimal_quantity/static/src/js/website_sale_utils.js @@ -0,0 +1,98 @@ +odoo.define('website_decimal_quantity.utils', function (require) { +'use strict'; + +var utils = require('website_sale.utils'); +var utilsDecimalQty = utils.updateCartNavBar; + +/** +* This module overrides the updateCartNavBar function of the website_sale.utils module to provide +* support for decimal quantities in the cart. +* It replaces the cart quantity text with an animated icon and updates the cart summary and lines with the new data. +*/ + +utils.updateCartNavBar = function(data) { + // Ensure cart_quantity is treated as a number for proper evaluation + var cartQty = parseFloat(data.cart_quantity) || 0; + // Round to 2 decimal places to avoid floating point precision issues + var displayQty = Math.round(cartQty * 100) / 100; + + $(".my_cart_quantity") + .parents('li.o_wsale_my_cart').removeClass('d-none').end() + .addClass('o_mycart_zoom_animation').delay(300) + .queue(function () { + $(this) + .toggleClass('fa fa-warning', cartQty === 0) + .attr('title', data.warning) + .text(displayQty > 0 ? displayQty : '') + .removeClass('o_mycart_zoom_animation') + .dequeue(); + }); + + $(".js_cart_lines").first().before(data['website_sale.cart_lines']).end().remove(); + $(".js_cart_summary").replaceWith(data['website_sale.short_cart_summary']); +} + +/** +* Override _addToCartInPage to handle decimal quantities correctly in the animation condition +*/ +var originalAddToCart = utils.cartHandlerMixin.addToCart; +var originalAddToCartInPage = utils.cartHandlerMixin._addToCartInPage; + +utils.cartHandlerMixin._addToCartInPage = function(params) { + var self = this; + params.force_create = true; + return this._rpc({ + route: "/shop/cart/update_json", + params: params, + }).then(async function(data) { + console.log('Data received from cart update:', data); + + // The response might not include cart_quantity if display=false or on error + // In that case, we need to make another call to get the total or calculate it + var currentQty = parseFloat($(".my_cart_quantity").text()) || 0; + var newQty; + + if (data.cart_quantity !== undefined) { + newQty = parseFloat(data.cart_quantity) || 0; + } else { + // If cart_quantity is not in response, add the quantity that was just added + newQty = currentQty + parseFloat(params.add_qty || 0); + // Ensure we have the templates by making the call with display=true + if (!data['website_sale.cart_lines']) { + // Make another call to get the rendered templates + var displayData = await self._rpc({ + route: "/shop/cart/update_json", + params: { + product_id: params.product_id, + line_id: data.line_id, + set_qty: data.quantity, + display: true, + force_create: true + }, + }); + // Merge the data + Object.assign(data, displayData); + newQty = parseFloat(displayData.cart_quantity) || newQty; + } + } + + sessionStorage.setItem('website_sale_cart_quantity', newQty); + + console.log('Current Qty:', currentQty, 'New Qty:', newQty, 'Data:', data); + + // Animate if quantity changed and is greater than 0 + if (newQty > 0 && newQty !== currentQty) { + // Ensure data has cart_quantity for updateCartNavBar + if (data.cart_quantity === undefined) { + data.cart_quantity = newQty; + } + // No animation if the product's page images are hidden + if ($('div[data-image_width]').data('image_width') !== 'none') { + await utils.animateClone($('header .o_wsale_my_cart').first(), self.$itemImgContainer, 25, 40); + } + utils.updateCartNavBar(data); + } + }); +}; + +}); diff --git a/website_decimal_quantity/views/uom_category_views.xml b/website_decimal_quantity/views/uom_category_views.xml new file mode 100644 index 0000000..9b97568 --- /dev/null +++ b/website_decimal_quantity/views/uom_category_views.xml @@ -0,0 +1,13 @@ + + + + uom.category.form.inherit + uom.category + + + + + + + + diff --git a/website_decimal_quantity/views/website_sale_templates.xml b/website_decimal_quantity/views/website_sale_templates.xml new file mode 100644 index 0000000..131fcc2 --- /dev/null +++ b/website_decimal_quantity/views/website_sale_templates.xml @@ -0,0 +1,32 @@ + + + + + + + + + + diff --git a/website_search_products/controllers/controller.py b/website_search_products/controllers/controller.py index ed2d861..204850e 100644 --- a/website_search_products/controllers/controller.py +++ b/website_search_products/controllers/controller.py @@ -38,23 +38,10 @@ class InventoryForm(http.Controller): class WebsiteController(Website): """Extend Website controller to redirect portal users after login.""" - @http.route(website=True, auth="public") - def web_login(self, redirect=None, *args, **kw): - """Handle login redirection based on user group.""" - response = super().web_login(redirect=redirect, *args, **kw) - - # Check if login was successful + def _login_redirect(self, uid, redirect=None): + """Send non-internal users to the website home instead of /my, + unless an explicit redirect was requested.""" if not redirect and request.params.get("login_success"): - user = request.env["res.users"].browse(request.uid) - - # Redirect internal users to web, others to inventory search - if user.has_group("base.group_user"): - # Keep original redirect for internal users - redirect_url = "/web" - else: - # Redirect portal users to inventory search - redirect_url = "/inventory/search" - - return werkzeug_redirect(redirect_url) - - return response + if not request.env["res.users"].browse(uid)._is_internal(): + redirect = "/" + return super()._login_redirect(uid, redirect=redirect)