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>
|
||||
1
hr_timesheet_sheet_holidays/__init__.py
Normal file
1
hr_timesheet_sheet_holidays/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import models
|
||||
17
hr_timesheet_sheet_holidays/__manifest__.py
Normal file
17
hr_timesheet_sheet_holidays/__manifest__.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# Copyright 2026 Criptomart
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
|
||||
|
||||
{
|
||||
"name": "HR Timesheet Sheet - Time Off",
|
||||
"version": "16.0.1.0.0",
|
||||
"category": "Human Resources",
|
||||
"summary": "Make time off entries inert but visible inside timesheet sheets",
|
||||
"author": "Criptomart, Odoo Community Association (OCA)",
|
||||
"website": "https://github.com/OCA/timesheet",
|
||||
"license": "AGPL-3",
|
||||
"depends": ["hr_timesheet_sheet", "project_timesheet_holidays"],
|
||||
"data": [],
|
||||
"installable": True,
|
||||
"auto_install": True,
|
||||
"development_status": "Beta",
|
||||
}
|
||||
41
hr_timesheet_sheet_holidays/i18n/es.po
Normal file
41
hr_timesheet_sheet_holidays/i18n/es.po
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * hr_timesheet_sheet_holidays
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server 16.0\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: \n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: \n"
|
||||
"Language: es\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: \n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
|
||||
#. module: hr_timesheet_sheet_holidays
|
||||
#: model:ir.model,name:hr_timesheet_sheet_holidays.model_account_analytic_line
|
||||
msgid "Analytic Line"
|
||||
msgstr "Línea analítica"
|
||||
|
||||
#. module: hr_timesheet_sheet_holidays
|
||||
#. odoo-python
|
||||
#: code:addons/hr_timesheet_sheet_holidays/models/account_analytic_line.py:0
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Time off hours are managed from the Time Off application and cannot be "
|
||||
"changed from a timesheet sheet.\n"
|
||||
"Sheet: %(sheet)s\n"
|
||||
"Day: %(day)s"
|
||||
msgstr ""
|
||||
"Las horas de una ausencia se gestionan desde la aplicación Ausencias y no se "
|
||||
"pueden modificar desde una hoja de servicio.\n"
|
||||
"Hoja: %(sheet)s\n"
|
||||
"Día: %(day)s"
|
||||
|
||||
#. module: hr_timesheet_sheet_holidays
|
||||
#: model:ir.model,name:hr_timesheet_sheet_holidays.model_hr_timesheet_sheet
|
||||
msgid "Timesheet Sheet"
|
||||
msgstr "Hoja de servicio"
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * hr_timesheet_sheet_holidays
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server 16.0\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: \n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: \n"
|
||||
"Plural-Forms: \n"
|
||||
|
||||
#. module: hr_timesheet_sheet_holidays
|
||||
#: model:ir.model,name:hr_timesheet_sheet_holidays.model_account_analytic_line
|
||||
msgid "Analytic Line"
|
||||
msgstr ""
|
||||
|
||||
#. module: hr_timesheet_sheet_holidays
|
||||
#. odoo-python
|
||||
#: code:addons/hr_timesheet_sheet_holidays/models/account_analytic_line.py:0
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Time off hours are managed from the Time Off application and cannot be "
|
||||
"changed from a timesheet sheet.\n"
|
||||
"Sheet: %(sheet)s\n"
|
||||
"Day: %(day)s"
|
||||
msgstr ""
|
||||
|
||||
#. module: hr_timesheet_sheet_holidays
|
||||
#: model:ir.model,name:hr_timesheet_sheet_holidays.model_hr_timesheet_sheet
|
||||
msgid "Timesheet Sheet"
|
||||
msgstr ""
|
||||
2
hr_timesheet_sheet_holidays/models/__init__.py
Normal file
2
hr_timesheet_sheet_holidays/models/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from . import account_analytic_line
|
||||
from . import hr_timesheet_sheet
|
||||
74
hr_timesheet_sheet_holidays/models/account_analytic_line.py
Normal file
74
hr_timesheet_sheet_holidays/models/account_analytic_line.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
# Copyright 2026 Criptomart
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
|
||||
|
||||
from odoo import _, models
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tools.misc import format_date
|
||||
|
||||
# The context of an RPC call is controlled by the client: odoo.api.call_kw
|
||||
# rebuilds the environment with whatever dictionary arrives in the request, so
|
||||
# a flag carrying a JSON value could be forged by any authenticated user. These
|
||||
# sentinels are compared by identity and cannot be produced by a deserializer,
|
||||
# so they only ever reach the consumer from inside this process.
|
||||
_SHEET_LINK_WRITE = object()
|
||||
_SHEET_RECOMPUTE = object()
|
||||
|
||||
|
||||
class AccountAnalyticLine(models.Model):
|
||||
_inherit = "account.analytic.line"
|
||||
|
||||
def _sheet_neutral_fields(self):
|
||||
"""Hook for extensions"""
|
||||
return {"sheet_id"}
|
||||
|
||||
def _sheet_link_write_token(self):
|
||||
"""Hook for extensions"""
|
||||
return _SHEET_LINK_WRITE
|
||||
|
||||
def _sheet_recompute_token(self):
|
||||
"""Hook for extensions"""
|
||||
return _SHEET_RECOMPUTE
|
||||
|
||||
def _in_sheet_link_write(self):
|
||||
return (
|
||||
self.env.context.get("sheet_link_write") is self._sheet_link_write_token()
|
||||
)
|
||||
|
||||
def _detach_leave_lines_on_unlink(self):
|
||||
"""Hook for extensions"""
|
||||
return self.env.context.get("sheet_recompute") is self._sheet_recompute_token()
|
||||
|
||||
def _check_can_update_timesheet(self):
|
||||
if self._in_sheet_link_write():
|
||||
return True
|
||||
if not self.env.su and self.filtered("holiday_id"):
|
||||
return False
|
||||
return super()._check_can_update_timesheet()
|
||||
|
||||
def write(self, vals):
|
||||
if vals and self.filtered("holiday_id"):
|
||||
if set(vals) <= self._sheet_neutral_fields():
|
||||
linking = self.with_context(
|
||||
sheet_link_write=self._sheet_link_write_token()
|
||||
)
|
||||
return super(AccountAnalyticLine, linking).write(vals)
|
||||
if not self._check_can_update_timesheet():
|
||||
line = self.filtered("holiday_id")[:1]
|
||||
raise UserError(
|
||||
_(
|
||||
"Time off hours are managed from the Time Off application"
|
||||
" and cannot be changed from a timesheet sheet.\n"
|
||||
"Sheet: %(sheet)s\n"
|
||||
"Day: %(day)s",
|
||||
sheet=line.sheet_id.complete_name or "-",
|
||||
day=format_date(self.env, line.date),
|
||||
)
|
||||
)
|
||||
return super().write(vals)
|
||||
|
||||
def unlink(self):
|
||||
leave_lines = self.filtered("holiday_id")
|
||||
if leave_lines and leave_lines._detach_leave_lines_on_unlink():
|
||||
leave_lines.write({"sheet_id": False})
|
||||
return super(AccountAnalyticLine, self - leave_lines).unlink()
|
||||
return super().unlink()
|
||||
21
hr_timesheet_sheet_holidays/models/hr_timesheet_sheet.py
Normal file
21
hr_timesheet_sheet_holidays/models/hr_timesheet_sheet.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# Copyright 2026 Criptomart
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
|
||||
|
||||
from odoo import api, models
|
||||
|
||||
|
||||
class Sheet(models.Model):
|
||||
_inherit = "hr_timesheet.sheet"
|
||||
|
||||
def _with_sheet_recompute(self):
|
||||
"""Hook for extensions"""
|
||||
token = self.env["account.analytic.line"]._sheet_recompute_token()
|
||||
return self.with_context(sheet_recompute=token)
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
sheets = super(Sheet, self._with_sheet_recompute()).create(vals_list)
|
||||
return sheets.with_env(self.env)
|
||||
|
||||
def write(self, vals):
|
||||
return super(Sheet, self._with_sheet_recompute()).write(vals)
|
||||
3
hr_timesheet_sheet_holidays/readme/CONTRIBUTORS.rst
Normal file
3
hr_timesheet_sheet_holidays/readme/CONTRIBUTORS.rst
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
* Criptomart
|
||||
|
||||
* Luis Díez <luis@criptomart.net>
|
||||
24
hr_timesheet_sheet_holidays/readme/DESCRIPTION.rst
Normal file
24
hr_timesheet_sheet_holidays/readme/DESCRIPTION.rst
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
``hr_timesheet_sheet`` works on every analytic line of a period at once, while
|
||||
``project_timesheet_holidays`` protects the analytic lines it generates for validated
|
||||
time off against any modification or deletion. Together they make any timesheet sheet
|
||||
that spans a day with validated time off unmanageable: opening, recomputing, creating or
|
||||
saving such a sheet raises a ``UserError`` and the employee cannot record their hours.
|
||||
|
||||
This bridge module makes time off lines inert with respect to the bulk operations of the
|
||||
sheet while keeping them visible inside it, so that the week is shown complete with its
|
||||
time off hours included. Two different code paths are involved and both are handled: the
|
||||
bulk ``write`` that links or unlinks ``sheet_id``, and the ``Command.DELETE`` the web
|
||||
client emits for the lines that leave the set cached by the onchange, which ends up in a
|
||||
real ``unlink()``. In the second case the time off line is detached from the sheet
|
||||
(``sheet_id = False``) instead of being deleted; the time off request and the employee
|
||||
balance are never touched.
|
||||
|
||||
The protection itself is not relaxed: the hours, date, project, task, employee and
|
||||
description of a time off line still cannot be changed from a timesheet sheet, the line
|
||||
still cannot be deleted by hand and new timesheets still cannot be created on a time off
|
||||
task. Only a write that touches nothing but the neutral link fields is let through.
|
||||
|
||||
The module also closes a pre-existing hole in ``hr_timesheet_sheet``, whose
|
||||
``_check_can_update_timesheet()`` returned ``True`` for any recordset without
|
||||
``sheet_id`` and therefore silently disabled the core protection for every time off line
|
||||
not linked to a sheet.
|
||||
5
hr_timesheet_sheet_holidays/readme/ROADMAP.rst
Normal file
5
hr_timesheet_sheet_holidays/readme/ROADMAP.rst
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
* The 2D matrix widget cannot make a single row read-only, so the time off row looks
|
||||
editable even though any attempt to change it is rejected. Making the cell read-only in
|
||||
the client would require an extension of ``web_widget_x2many_2d_matrix``.
|
||||
* Lines generated from public holidays (``global_leave_id``) are not covered: they are
|
||||
not protected by ``project_timesheet_holidays`` and therefore never blocked the sheet.
|
||||
39
hr_timesheet_sheet_holidays/readme/USAGE.rst
Normal file
39
hr_timesheet_sheet_holidays/readme/USAGE.rst
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
Nothing to configure. Once installed, a timesheet sheet covering a week with validated
|
||||
time off can be opened, recomputed, created and saved by the employee, and the time off
|
||||
lines are shown in the *Summary* matrix and in the *Details* tab with their project, task
|
||||
and hours, adding up to the total of the sheet.
|
||||
|
||||
Time off hours are still read-only from the sheet. Trying to change them raises an error
|
||||
pointing the user to the *Time Off* application, naming the sheet and the day concerned.
|
||||
To change them, cancel or modify the time off request in the *Time Off* application.
|
||||
|
||||
A time off line may end up detached from every sheet (``sheet_id = False``) when the
|
||||
period or the employee of a sheet is changed in the form. Nothing is lost: the line, the
|
||||
time off request and the balance are untouched, and the line is linked again as soon as a
|
||||
sheet covering that day is recomputed.
|
||||
|
||||
For developers, the module publishes two hooks on ``account.analytic.line``:
|
||||
|
||||
* ``_sheet_neutral_fields()`` returns the set of fields whose write is considered a pure
|
||||
sheet link operation (``{"sheet_id"}`` by default). Widening it requires reviewing the
|
||||
protection of time off hours.
|
||||
* ``_detach_leave_lines_on_unlink()`` returns whether an ``unlink()`` of time off lines
|
||||
must detach them instead of deleting them.
|
||||
|
||||
Both behaviours are driven by the internal context flags ``sheet_link_write`` and
|
||||
``sheet_recompute``. The context of an RPC call is controlled by the client, so those
|
||||
flags do **not** carry a boolean: they carry a module-level sentinel object, obtained
|
||||
from ``_sheet_link_write_token()`` / ``_sheet_recompute_token()`` and compared by
|
||||
identity. A JSON deserializer cannot produce such an object, so the flags cannot be
|
||||
forged from outside the process. Never replace a token with a value that survives
|
||||
serialization, and never set either flag from a view, an action or a user context.
|
||||
|
||||
Three tests are mandatory regression tests for any change to those flags:
|
||||
|
||||
* ``test_no_context_leak_after_sheet_operation`` — the flag does not escape: right after
|
||||
a sheet operation, in the same transaction, a direct write or unlink on a time off line
|
||||
is blocked again.
|
||||
* ``test_forged_sheet_link_write_flag_does_not_unlock_write`` and
|
||||
``test_forged_sheet_recompute_flag_does_not_unlock_unlink`` — the flag cannot be
|
||||
injected: a boolean sent through ``odoo.api.call_kw``, the entry point the web client
|
||||
uses, unlocks nothing.
|
||||
1
hr_timesheet_sheet_holidays/tests/__init__.py
Normal file
1
hr_timesheet_sheet_holidays/tests/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import test_hr_timesheet_sheet_holidays
|
||||
|
|
@ -0,0 +1,509 @@
|
|||
# Copyright 2026 Criptomart
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
|
||||
|
||||
import json
|
||||
from datetime import date, datetime
|
||||
|
||||
from odoo.api import call_kw
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tests import tagged
|
||||
from odoo.tests.common import Form, TransactionCase
|
||||
|
||||
CORE_WRITE_ERROR = "You cannot modify timesheets that are linked to time off requests"
|
||||
|
||||
|
||||
@tagged("post_install", "-at_install")
|
||||
class TestHrTimesheetSheetHolidays(TransactionCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.env = cls.env(
|
||||
context=dict(
|
||||
cls.env.context,
|
||||
tracking_disable=True,
|
||||
no_reset_password=True,
|
||||
mail_create_nolog=True,
|
||||
mail_notrack=True,
|
||||
)
|
||||
)
|
||||
cls.week_start = date(2026, 9, 7)
|
||||
cls.week_end = date(2026, 9, 13)
|
||||
cls.previous_week_start = date(2026, 8, 31)
|
||||
cls.previous_week_end = date(2026, 9, 6)
|
||||
cls.leave_start = datetime(2026, 9, 7, 6, 0, 0)
|
||||
cls.leave_end = datetime(2026, 9, 11, 18, 0, 0)
|
||||
|
||||
cls.company = cls.env["res.company"].create({"name": "Timesheet Holidays Co"})
|
||||
cls.env.user.write({"company_ids": [(4, cls.company.id)]})
|
||||
cls.company.resource_calendar_id.tz = "UTC"
|
||||
cls.internal_project = cls.company.internal_project_id
|
||||
cls.timeoff_task = cls.company.leave_timesheet_task_id
|
||||
|
||||
cls.user = cls._new_user("tsh_holidays_employee")
|
||||
cls.user_other = cls._new_user("tsh_holidays_other")
|
||||
cls.user_third = cls._new_user("tsh_holidays_third")
|
||||
# Only a timesheet manager can open a sheet on behalf of another
|
||||
# employee, so this is the role that reaches the "change the employee"
|
||||
# sequence of R2. It is not a superuser: env.su stays False and the
|
||||
# time off protection is fully active for it.
|
||||
cls.user_manager = cls._new_user(
|
||||
"tsh_holidays_manager", ("hr_timesheet.group_timesheet_manager",)
|
||||
)
|
||||
cls.employee = cls._new_employee("Sheet Employee", cls.user)
|
||||
cls.employee_other = cls._new_employee("Other Employee", cls.user_other)
|
||||
cls.employee_third = cls._new_employee("Third Employee", cls.user_third)
|
||||
|
||||
cls.project = cls.env["project.project"].create(
|
||||
{
|
||||
"name": "Timesheet Holidays Project",
|
||||
"company_id": cls.company.id,
|
||||
"allow_timesheets": True,
|
||||
"is_timesheet_task_required": False,
|
||||
}
|
||||
)
|
||||
|
||||
cls.leave_type = cls.env["hr.leave.type"].create(
|
||||
{
|
||||
"name": "Paid Time Off (test)",
|
||||
"company_id": cls.company.id,
|
||||
"requires_allocation": "yes",
|
||||
"allocation_validation_type": "no",
|
||||
"leave_validation_type": "no_validation",
|
||||
"timesheet_generate": True,
|
||||
"timesheet_project_id": cls.internal_project.id,
|
||||
"timesheet_task_id": cls.timeoff_task.id,
|
||||
}
|
||||
)
|
||||
cls._allocate(cls.employee)
|
||||
cls._allocate(cls.employee_third)
|
||||
|
||||
cls.leave = cls._request_leave(cls.employee, cls.leave_start, cls.leave_end)
|
||||
cls.leave_third = cls._request_leave(
|
||||
cls.employee_third, cls.leave_start, cls.leave_end
|
||||
)
|
||||
cls.leave_lines = cls.leave.sudo().timesheet_ids
|
||||
cls.leave_lines_third = cls.leave_third.sudo().timesheet_ids
|
||||
|
||||
cls.sheet_third = cls.env["hr_timesheet.sheet"].create(
|
||||
{
|
||||
"employee_id": cls.employee_third.id,
|
||||
"company_id": cls.company.id,
|
||||
"date_start": cls.week_start,
|
||||
"date_end": cls.week_end,
|
||||
}
|
||||
)
|
||||
cls.sheet_third._compute_timesheet_ids()
|
||||
|
||||
@classmethod
|
||||
def _new_user(cls, login, extra_groups=()):
|
||||
return (
|
||||
cls.env["res.users"]
|
||||
.with_context(no_reset_password=True, default_role_line_ids=[])
|
||||
.create(
|
||||
{
|
||||
"name": login,
|
||||
"login": login,
|
||||
"email": "%s@example.org" % login,
|
||||
"company_id": cls.company.id,
|
||||
"company_ids": [(6, 0, [cls.company.id])],
|
||||
"groups_id": [
|
||||
(
|
||||
6,
|
||||
0,
|
||||
[
|
||||
cls.env.ref("hr_timesheet.group_hr_timesheet_user").id,
|
||||
cls.env.ref("project.group_project_user").id,
|
||||
# Third party add-ons read project_id.stage_id
|
||||
# when a timesheet line is written, and that
|
||||
# field is protected by this group.
|
||||
cls.env.ref("project.group_project_stages").id,
|
||||
]
|
||||
+ [cls.env.ref(xmlid).id for xmlid in extra_groups],
|
||||
)
|
||||
],
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _new_employee(cls, name, user):
|
||||
return cls.env["hr.employee"].create(
|
||||
{"name": name, "user_id": user.id, "company_id": cls.company.id}
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _allocate(cls, employee):
|
||||
allocation = cls.env["hr.leave.allocation"].create(
|
||||
{
|
||||
"name": "Allocation for %s" % employee.name,
|
||||
"holiday_status_id": cls.leave_type.id,
|
||||
"employee_id": employee.id,
|
||||
"number_of_days": 25,
|
||||
"date_from": date(2026, 1, 1),
|
||||
}
|
||||
)
|
||||
if allocation.state != "validate":
|
||||
allocation.action_validate()
|
||||
return allocation
|
||||
|
||||
@classmethod
|
||||
def _request_leave(cls, employee, date_from, date_to):
|
||||
return cls.env["hr.leave"].create(
|
||||
{
|
||||
"name": "Time off for %s" % employee.name,
|
||||
"holiday_status_id": cls.leave_type.id,
|
||||
"employee_id": employee.id,
|
||||
"date_from": date_from,
|
||||
"date_to": date_to,
|
||||
"number_of_days": 5,
|
||||
}
|
||||
)
|
||||
|
||||
# Helpers
|
||||
|
||||
def _sheet_form(self, user=None):
|
||||
return Form(self.env["hr_timesheet.sheet"].with_user(user or self.user))
|
||||
|
||||
def _leaves_taken(self, employee):
|
||||
leave_type = self.leave_type.with_context(employee_id=employee.id)
|
||||
leave_type.invalidate_recordset()
|
||||
return leave_type.leaves_taken
|
||||
|
||||
def _assert_leave_lines_survived(self, lines, leaves_taken_before):
|
||||
self.assertEqual(lines.exists(), lines)
|
||||
self.assertFalse(lines.mapped("sheet_id"))
|
||||
self.assertEqual(self.leave.state, "validate")
|
||||
self.assertEqual(self._leaves_taken(self.employee), leaves_taken_before)
|
||||
|
||||
def _assert_leave_line_is_protected(self, line, user):
|
||||
line = line.with_user(user)
|
||||
with self.assertRaises(UserError):
|
||||
line.write({"unit_amount": 3.0})
|
||||
self.env.invalidate_all()
|
||||
with self.assertRaises(UserError):
|
||||
line.unlink()
|
||||
self.env.invalidate_all()
|
||||
self.assertFalse(self.env.context.get("sheet_link_write"))
|
||||
self.assertFalse(self.env.context.get("sheet_recompute"))
|
||||
|
||||
# Fixture sanity
|
||||
|
||||
def test_fixture_generated_leave_lines(self):
|
||||
self.assertEqual(len(self.leave_lines), 5)
|
||||
self.assertEqual(self.leave.state, "validate")
|
||||
self.assertEqual(self.leave_lines.mapped("task_id"), self.timeoff_task)
|
||||
|
||||
# Published hooks
|
||||
|
||||
def test_sheet_neutral_fields_default(self):
|
||||
lines = self.env["account.analytic.line"]
|
||||
self.assertEqual(lines._sheet_neutral_fields(), {"sheet_id"})
|
||||
|
||||
def test_detach_leave_lines_on_unlink_hook(self):
|
||||
lines = self.env["account.analytic.line"]
|
||||
self.assertFalse(lines._detach_leave_lines_on_unlink())
|
||||
recomposing = lines.with_context(
|
||||
sheet_recompute=lines._sheet_recompute_token()
|
||||
)
|
||||
self.assertTrue(recomposing._detach_leave_lines_on_unlink())
|
||||
|
||||
def test_context_tokens_are_not_serializable(self):
|
||||
lines = self.env["account.analytic.line"]
|
||||
for token in (lines._sheet_link_write_token(), lines._sheet_recompute_token()):
|
||||
with self.assertRaises(TypeError):
|
||||
json.dumps(token)
|
||||
|
||||
# RF-01
|
||||
|
||||
def test_sheet_recompute_with_leave_lines(self):
|
||||
sheet = (
|
||||
self.env["hr_timesheet.sheet"]
|
||||
.with_user(self.user)
|
||||
.create(
|
||||
{
|
||||
"employee_id": self.employee.id,
|
||||
"company_id": self.company.id,
|
||||
"date_start": self.week_start,
|
||||
"date_end": self.week_end,
|
||||
}
|
||||
)
|
||||
)
|
||||
sheet._compute_timesheet_ids()
|
||||
self.assertTrue(sheet.timesheet_ids.filtered("holiday_id"))
|
||||
self.assertEqual(
|
||||
sheet.timesheet_ids.filtered("holiday_id"), self.leave_lines
|
||||
)
|
||||
self.assertEqual(self.leave_lines.mapped("sheet_id"), sheet.sudo())
|
||||
sheet.write({"timesheet_ids": [(6, 0, sheet.timesheet_ids.ids)]})
|
||||
self.assertTrue(sheet.timesheet_ids.filtered("holiday_id"))
|
||||
|
||||
def test_form_create_over_leave_week(self):
|
||||
form = self._sheet_form()
|
||||
form.employee_id = self.employee
|
||||
form.date_start = self.week_start
|
||||
form.date_end = self.week_end
|
||||
sheet = form.save()
|
||||
self.assertEqual(len(sheet.timesheet_ids.filtered("holiday_id")), 5)
|
||||
self.assertEqual(self.leave_lines.mapped("sheet_id"), sheet.sudo())
|
||||
|
||||
# RF-02
|
||||
|
||||
def test_form_create_moving_period_keeps_leave_lines(self):
|
||||
taken = self._leaves_taken(self.employee)
|
||||
form = self._sheet_form()
|
||||
form.employee_id = self.employee
|
||||
form.date_start = self.week_start
|
||||
form.date_end = self.week_end
|
||||
form.date_start = self.previous_week_start
|
||||
form.date_end = self.previous_week_end
|
||||
sheet = form.save()
|
||||
self.assertFalse(sheet.timesheet_ids.filtered("holiday_id"))
|
||||
self._assert_leave_lines_survived(self.leave_lines, taken)
|
||||
|
||||
def test_form_create_changing_employee_keeps_leave_lines(self):
|
||||
taken = self._leaves_taken(self.employee)
|
||||
self.assertFalse(self.env(user=self.user_manager).su)
|
||||
form = self._sheet_form(self.user_manager)
|
||||
form.employee_id = self.employee
|
||||
form.date_start = self.week_start
|
||||
form.date_end = self.week_end
|
||||
form.employee_id = self.employee_other
|
||||
sheet = form.save()
|
||||
self.assertEqual(sheet.employee_id, self.employee_other)
|
||||
self.assertFalse(sheet.timesheet_ids.filtered("holiday_id"))
|
||||
self._assert_leave_lines_survived(self.leave_lines, taken)
|
||||
|
||||
def test_form_create_shrinking_period_keeps_leave_lines(self):
|
||||
taken = self._leaves_taken(self.employee)
|
||||
kept_day = date(2026, 9, 8)
|
||||
form = self._sheet_form()
|
||||
form.employee_id = self.employee
|
||||
form.date_start = self.week_start
|
||||
form.date_end = self.week_end
|
||||
form.date_end = kept_day
|
||||
sheet = form.save()
|
||||
dropped = self.leave_lines.filtered(lambda line: line.date > kept_day)
|
||||
self.assertTrue(dropped)
|
||||
self.assertEqual(dropped.exists(), dropped)
|
||||
self.assertFalse(dropped.mapped("sheet_id"))
|
||||
self.assertEqual(
|
||||
sheet.timesheet_ids.filtered("holiday_id"),
|
||||
(self.leave_lines - dropped),
|
||||
)
|
||||
self.assertEqual(self.leave.state, "validate")
|
||||
self.assertEqual(self._leaves_taken(self.employee), taken)
|
||||
|
||||
def test_unlink_with_sheet_recompute_detaches_leave_lines(self):
|
||||
sheet = self._create_linked_sheet()
|
||||
lines = self.leave_lines
|
||||
lines.with_user(self.user).with_context(
|
||||
sheet_recompute=lines._sheet_recompute_token()
|
||||
).unlink()
|
||||
self.assertEqual(lines.exists(), lines)
|
||||
self.assertFalse(lines.mapped("sheet_id"))
|
||||
self.assertFalse(sheet.timesheet_ids.filtered("holiday_id"))
|
||||
|
||||
def test_unlink_with_sheet_recompute_deletes_regular_lines(self):
|
||||
sheet = self._create_linked_sheet()
|
||||
regular = self._create_regular_line()
|
||||
sheet._compute_timesheet_ids()
|
||||
mixed = (self.leave_lines | regular.sudo()).with_user(self.user)
|
||||
mixed.with_context(sheet_recompute=mixed._sheet_recompute_token()).unlink()
|
||||
self.assertFalse(regular.exists())
|
||||
self.assertEqual(self.leave_lines.exists(), self.leave_lines)
|
||||
|
||||
# RF-03
|
||||
|
||||
def test_leave_line_hours_are_protected(self):
|
||||
self._create_linked_sheet()
|
||||
line = self.leave_lines[0]
|
||||
self.assertTrue(line.sheet_id)
|
||||
with self.assertRaises(UserError):
|
||||
line.with_user(self.user).write({"unit_amount": 99.0})
|
||||
self.env.invalidate_all()
|
||||
self.assertNotEqual(line.unit_amount, 99.0)
|
||||
|
||||
def test_leave_line_without_sheet_hours_are_protected(self):
|
||||
line = self.leave_lines[0]
|
||||
self.assertFalse(line.sheet_id)
|
||||
self.assertFalse(line.with_user(self.user)._check_can_update_timesheet())
|
||||
with self.assertRaises(UserError):
|
||||
line.with_user(self.user).write({"unit_amount": 99.0})
|
||||
self.env.invalidate_all()
|
||||
self.assertNotEqual(line.unit_amount, 99.0)
|
||||
|
||||
def test_leave_line_hours_protected_inside_sheet_operation(self):
|
||||
line = self.leave_lines[0].with_user(self.user)
|
||||
with self.assertRaises(UserError):
|
||||
line.with_context(
|
||||
sheet_recompute=line._sheet_recompute_token()
|
||||
).write({"unit_amount": 99.0})
|
||||
self.env.invalidate_all()
|
||||
self.assertNotEqual(self.leave_lines[0].unit_amount, 99.0)
|
||||
|
||||
def test_leave_line_mixed_write_is_protected(self):
|
||||
sheet = self._create_linked_sheet()
|
||||
line = self.leave_lines[0].with_user(self.user)
|
||||
with self.assertRaises(UserError):
|
||||
line.write({"sheet_id": sheet.id, "unit_amount": 99.0})
|
||||
self.env.invalidate_all()
|
||||
self.assertNotEqual(self.leave_lines[0].unit_amount, 99.0)
|
||||
|
||||
def test_leave_line_other_fields_are_protected(self):
|
||||
line = self.leave_lines[0].with_user(self.user)
|
||||
for vals in (
|
||||
{"date": date(2026, 9, 12)},
|
||||
{"name": "hacked"},
|
||||
{"project_id": self.project.id},
|
||||
{"employee_id": self.employee_other.id},
|
||||
):
|
||||
self.env.invalidate_all()
|
||||
with self.assertRaises(UserError):
|
||||
line.write(vals)
|
||||
|
||||
def test_leave_line_cannot_be_deleted(self):
|
||||
self._create_linked_sheet()
|
||||
line = self.leave_lines[0]
|
||||
with self.assertRaises(UserError):
|
||||
line.with_user(self.user).unlink()
|
||||
self.env.invalidate_all()
|
||||
self.assertTrue(line.exists())
|
||||
|
||||
def test_cannot_create_timesheet_on_timeoff_task(self):
|
||||
with self.assertRaises(UserError):
|
||||
self.env["account.analytic.line"].with_user(self.user).create(
|
||||
{
|
||||
"name": "sneaky",
|
||||
"project_id": self.internal_project.id,
|
||||
"task_id": self.timeoff_task.id,
|
||||
"employee_id": self.employee.id,
|
||||
"date": self.week_start,
|
||||
"unit_amount": 4.0,
|
||||
}
|
||||
)
|
||||
|
||||
# RF-04
|
||||
|
||||
def test_leave_line_visible_in_matrix(self):
|
||||
sheet = self._create_linked_sheet()
|
||||
self._create_regular_line()
|
||||
sheet._compute_timesheet_ids()
|
||||
sheet._compute_line_ids()
|
||||
leave_rows = sheet.line_ids.filtered(
|
||||
lambda line: line.project_id == self.internal_project
|
||||
)
|
||||
self.assertTrue(leave_rows)
|
||||
self.assertTrue(sum(leave_rows.mapped("unit_amount")) > 0)
|
||||
self.assertEqual(
|
||||
sum(sheet.timesheet_ids.mapped("unit_amount")), sheet.total_time
|
||||
)
|
||||
self.assertTrue(sheet.total_time >= sum(self.leave_lines.mapped("unit_amount")))
|
||||
|
||||
# RF-05
|
||||
|
||||
def test_leave_line_error_message(self):
|
||||
sheet = self._create_linked_sheet()
|
||||
line = self.leave_lines[0]
|
||||
with self.assertRaises(UserError) as catcher:
|
||||
line.with_user(self.user).write({"unit_amount": 99.0})
|
||||
message = str(catcher.exception)
|
||||
self.assertNotIn(CORE_WRITE_ERROR, message)
|
||||
self.assertIn("Time Off application", message)
|
||||
self.assertIn(sheet.complete_name, message)
|
||||
self.assertIn("Day:", message)
|
||||
|
||||
# RF-06
|
||||
|
||||
def test_forged_sheet_link_write_flag_does_not_unlock_write(self):
|
||||
line = self.leave_lines[0]
|
||||
model = self.env["account.analytic.line"].with_user(self.user)
|
||||
with self.assertRaises(UserError):
|
||||
call_kw(
|
||||
model,
|
||||
"write",
|
||||
[[line.id], {"unit_amount": 99.0}],
|
||||
{"context": {"sheet_link_write": True}},
|
||||
)
|
||||
self.env.invalidate_all()
|
||||
self.assertNotEqual(self.leave_lines[0].unit_amount, 99.0)
|
||||
self.assertFalse(
|
||||
model.with_context(sheet_link_write=True)._in_sheet_link_write()
|
||||
)
|
||||
|
||||
def test_forged_sheet_recompute_flag_does_not_unlock_unlink(self):
|
||||
sheet = self._create_linked_sheet()
|
||||
line = self.leave_lines[0]
|
||||
model = self.env["account.analytic.line"].with_user(self.user)
|
||||
with self.assertRaises(UserError):
|
||||
call_kw(
|
||||
model,
|
||||
"unlink",
|
||||
[[line.id]],
|
||||
{"context": {"sheet_recompute": True}},
|
||||
)
|
||||
self.env.invalidate_all()
|
||||
self.assertTrue(line.exists())
|
||||
self.assertEqual(line.sheet_id, sheet.sudo())
|
||||
self.assertFalse(
|
||||
model.with_context(
|
||||
sheet_recompute=True
|
||||
)._detach_leave_lines_on_unlink()
|
||||
)
|
||||
|
||||
def test_no_context_leak_after_sheet_operation(self):
|
||||
form = self._sheet_form()
|
||||
form.employee_id = self.employee
|
||||
form.date_start = self.week_start
|
||||
form.date_end = self.week_end
|
||||
form.date_start = self.previous_week_start
|
||||
form.date_end = self.previous_week_end
|
||||
sheet = form.save()
|
||||
self.assertFalse(sheet.env.context.get("sheet_recompute"))
|
||||
self.assertFalse(sheet.env.context.get("sheet_link_write"))
|
||||
self._assert_leave_line_is_protected(self.leave_lines[0], self.user)
|
||||
self._assert_leave_line_is_protected(
|
||||
self.leave_lines_third[0], self.user_third
|
||||
)
|
||||
|
||||
self.sheet_third.with_user(self.user_third).write(
|
||||
{"timesheet_ids": [(6, 0, self.sheet_third.timesheet_ids.ids)]}
|
||||
)
|
||||
self.assertFalse(self.env.context.get("sheet_recompute"))
|
||||
self.assertFalse(self.env.context.get("sheet_link_write"))
|
||||
self._assert_leave_line_is_protected(self.leave_lines[0], self.user)
|
||||
self._assert_leave_line_is_protected(
|
||||
self.leave_lines_third[0], self.user_third
|
||||
)
|
||||
|
||||
# Internal helpers
|
||||
|
||||
def _create_linked_sheet(self):
|
||||
sheet = (
|
||||
self.env["hr_timesheet.sheet"]
|
||||
.with_user(self.user)
|
||||
.create(
|
||||
{
|
||||
"employee_id": self.employee.id,
|
||||
"company_id": self.company.id,
|
||||
"date_start": self.week_start,
|
||||
"date_end": self.week_end,
|
||||
}
|
||||
)
|
||||
)
|
||||
sheet._compute_timesheet_ids()
|
||||
return sheet
|
||||
|
||||
def _create_regular_line(self):
|
||||
return (
|
||||
self.env["account.analytic.line"]
|
||||
.with_user(self.user)
|
||||
.create(
|
||||
{
|
||||
"name": "Regular work",
|
||||
"project_id": self.project.id,
|
||||
"employee_id": self.employee.id,
|
||||
"date": self.week_start,
|
||||
"unit_amount": 4.0,
|
||||
}
|
||||
)
|
||||
)
|
||||
2
hr_timesheet_sheet_no_review/__init__.py
Normal file
2
hr_timesheet_sheet_no_review/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from . import models
|
||||
from . import wizards
|
||||
23
hr_timesheet_sheet_no_review/__manifest__.py
Normal file
23
hr_timesheet_sheet_no_review/__manifest__.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# Copyright 2026 Criptomart
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
|
||||
|
||||
{
|
||||
"name": "HR Timesheet Sheet - No Review Workflow",
|
||||
"version": "16.0.1.0.0",
|
||||
"category": "Human Resources",
|
||||
"summary": "Hide the timesheet sheet review workflow and unfreeze pending sheets",
|
||||
"author": "Criptomart, Odoo Community Association (OCA)",
|
||||
"website": "https://github.com/OCA/timesheet",
|
||||
"license": "AGPL-3",
|
||||
"development_status": "Beta",
|
||||
"depends": ["hr_timesheet_sheet"],
|
||||
"data": [
|
||||
"security/ir.model.access.csv",
|
||||
"wizards/unfreeze_wizard_views.xml",
|
||||
"views/hr_timesheet_sheet_views.xml",
|
||||
"views/menus.xml",
|
||||
],
|
||||
"installable": True,
|
||||
"auto_install": False,
|
||||
"application": False,
|
||||
}
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * hr_timesheet_sheet_no_review
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server 16.0\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-09-15 07:59+0000\n"
|
||||
"PO-Revision-Date: 2026-09-15 07:59+0000\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: \n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: \n"
|
||||
"Plural-Forms: \n"
|
||||
|
||||
#. module: hr_timesheet_sheet_no_review
|
||||
#. odoo-python
|
||||
#: code:addons/hr_timesheet_sheet_no_review/wizards/unfreeze_wizard.py:0
|
||||
#, python-format
|
||||
msgid "%s timesheet sheet(s) have been set back to draft."
|
||||
msgstr ""
|
||||
|
||||
#. module: hr_timesheet_sheet_no_review
|
||||
#: model:ir.model.fields,field_description:hr_timesheet_sheet_no_review.field_hr_timesheet_sheet_unfreeze_wizard__timesheet_hours
|
||||
msgid "Blocked Hours"
|
||||
msgstr ""
|
||||
|
||||
#. module: hr_timesheet_sheet_no_review
|
||||
#: model:ir.model.fields,field_description:hr_timesheet_sheet_no_review.field_hr_timesheet_sheet_unfreeze_wizard__timesheet_count
|
||||
msgid "Blocked Timesheets"
|
||||
msgstr ""
|
||||
|
||||
#. module: hr_timesheet_sheet_no_review
|
||||
#: model_terms:ir.ui.view,arch_db:hr_timesheet_sheet_no_review.hr_timesheet_sheet_unfreeze_wizard_form
|
||||
msgid "Cancel"
|
||||
msgstr ""
|
||||
|
||||
#. module: hr_timesheet_sheet_no_review
|
||||
#: model:ir.model.fields,field_description:hr_timesheet_sheet_no_review.field_hr_timesheet_sheet_unfreeze_wizard__create_uid
|
||||
msgid "Created by"
|
||||
msgstr ""
|
||||
|
||||
#. module: hr_timesheet_sheet_no_review
|
||||
#: model:ir.model.fields,field_description:hr_timesheet_sheet_no_review.field_hr_timesheet_sheet_unfreeze_wizard__create_date
|
||||
msgid "Created on"
|
||||
msgstr ""
|
||||
|
||||
#. module: hr_timesheet_sheet_no_review
|
||||
#: model:ir.model.fields,field_description:hr_timesheet_sheet_no_review.field_hr_timesheet_sheet_unfreeze_wizard__display_name
|
||||
msgid "Display Name"
|
||||
msgstr ""
|
||||
|
||||
#. module: hr_timesheet_sheet_no_review
|
||||
#: model:ir.model.fields,field_description:hr_timesheet_sheet_no_review.field_hr_timesheet_sheet_unfreeze_wizard__sheet_count
|
||||
msgid "Frozen Sheets"
|
||||
msgstr ""
|
||||
|
||||
#. module: hr_timesheet_sheet_no_review
|
||||
#: model:ir.model.fields,field_description:hr_timesheet_sheet_no_review.field_hr_timesheet_sheet_unfreeze_wizard__id
|
||||
msgid "ID"
|
||||
msgstr ""
|
||||
|
||||
#. module: hr_timesheet_sheet_no_review
|
||||
#: model:ir.model.fields,field_description:hr_timesheet_sheet_no_review.field_hr_timesheet_sheet_unfreeze_wizard____last_update
|
||||
msgid "Last Modified on"
|
||||
msgstr ""
|
||||
|
||||
#. module: hr_timesheet_sheet_no_review
|
||||
#: model:ir.model.fields,field_description:hr_timesheet_sheet_no_review.field_hr_timesheet_sheet_unfreeze_wizard__write_uid
|
||||
msgid "Last Updated by"
|
||||
msgstr ""
|
||||
|
||||
#. module: hr_timesheet_sheet_no_review
|
||||
#: model:ir.model.fields,field_description:hr_timesheet_sheet_no_review.field_hr_timesheet_sheet_unfreeze_wizard__write_date
|
||||
msgid "Last Updated on"
|
||||
msgstr ""
|
||||
|
||||
#. module: hr_timesheet_sheet_no_review
|
||||
#. odoo-python
|
||||
#: code:addons/hr_timesheet_sheet_no_review/wizards/unfreeze_wizard.py:0
|
||||
#, python-format
|
||||
msgid "No frozen timesheet sheet was found. Nothing was changed."
|
||||
msgstr ""
|
||||
|
||||
#. module: hr_timesheet_sheet_no_review
|
||||
#: model:ir.model.fields,field_description:hr_timesheet_sheet_no_review.field_hr_timesheet_sheet_unfreeze_wizard__summary_html
|
||||
msgid "Summary"
|
||||
msgstr ""
|
||||
|
||||
#. module: hr_timesheet_sheet_no_review
|
||||
#. odoo-python
|
||||
#: code:addons/hr_timesheet_sheet_no_review/wizards/unfreeze_wizard.py:0
|
||||
#, python-format
|
||||
msgid ""
|
||||
"The timesheet sheets frozen by the review workflow, and the timesheet "
|
||||
"entries they keep locked, are:"
|
||||
msgstr ""
|
||||
|
||||
#. module: hr_timesheet_sheet_no_review
|
||||
#. odoo-python
|
||||
#: code:addons/hr_timesheet_sheet_no_review/wizards/unfreeze_wizard.py:0
|
||||
#, python-format
|
||||
msgid "There is no frozen timesheet sheet. Nothing to unfreeze."
|
||||
msgstr ""
|
||||
|
||||
#. module: hr_timesheet_sheet_no_review
|
||||
#: model_terms:ir.ui.view,arch_db:hr_timesheet_sheet_no_review.hr_timesheet_sheet_unfreeze_wizard_form
|
||||
msgid ""
|
||||
"This is a mass operation on historical data and it cannot be\n"
|
||||
" undone: the reviewer of every approved sheet is cleared. Make\n"
|
||||
" sure you have a recent backup before you continue."
|
||||
msgstr ""
|
||||
|
||||
#. module: hr_timesheet_sheet_no_review
|
||||
#: model:ir.model,name:hr_timesheet_sheet_no_review.model_hr_timesheet_sheet
|
||||
msgid "Timesheet Sheet"
|
||||
msgstr ""
|
||||
|
||||
#. module: hr_timesheet_sheet_no_review
|
||||
#: model_terms:ir.ui.view,arch_db:hr_timesheet_sheet_no_review.hr_timesheet_sheet_unfreeze_wizard_form
|
||||
msgid "Unfreeze"
|
||||
msgstr ""
|
||||
|
||||
#. module: hr_timesheet_sheet_no_review
|
||||
#. odoo-python
|
||||
#: code:addons/hr_timesheet_sheet_no_review/wizards/unfreeze_wizard.py:0
|
||||
#: model:ir.actions.act_window,name:hr_timesheet_sheet_no_review.action_hr_timesheet_sheet_unfreeze_wizard
|
||||
#: model:ir.ui.menu,name:hr_timesheet_sheet_no_review.menu_hr_timesheet_sheet_unfreeze
|
||||
#: model_terms:ir.ui.view,arch_db:hr_timesheet_sheet_no_review.hr_timesheet_sheet_unfreeze_wizard_form
|
||||
#, python-format
|
||||
msgid "Unfreeze Pending Sheets"
|
||||
msgstr ""
|
||||
|
||||
#. module: hr_timesheet_sheet_no_review
|
||||
#: model:ir.model,name:hr_timesheet_sheet_no_review.model_hr_timesheet_sheet_unfreeze_wizard
|
||||
msgid "Unfreeze Pending Timesheet Sheets"
|
||||
msgstr ""
|
||||
1
hr_timesheet_sheet_no_review/models/__init__.py
Normal file
1
hr_timesheet_sheet_no_review/models/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import hr_timesheet_sheet
|
||||
48
hr_timesheet_sheet_no_review/models/hr_timesheet_sheet.py
Normal file
48
hr_timesheet_sheet_no_review/models/hr_timesheet_sheet.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# Copyright 2026 Criptomart
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import api, models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HrTimesheetSheet(models.Model):
|
||||
_inherit = "hr_timesheet.sheet"
|
||||
|
||||
@api.model
|
||||
def _unfreeze_domain(self):
|
||||
"""Hook for extensions: which sheets are considered frozen."""
|
||||
return [("state", "in", ("confirm", "done"))]
|
||||
|
||||
@api.model
|
||||
def _unfreeze_sheets(self):
|
||||
"""Send frozen sheets back to draft. Returns the number of sheets affected.
|
||||
|
||||
The update is issued in SQL on purpose: going through ``write()`` would
|
||||
trigger the sheet recomputation and one tracking message per sheet, on a
|
||||
volume that is measured in thousands of records.
|
||||
"""
|
||||
sheets = self.search(self._unfreeze_domain())
|
||||
if not sheets:
|
||||
_logger.info("No frozen timesheet sheets to unfreeze.")
|
||||
return 0
|
||||
sheets.check_access_rights("write")
|
||||
sheets.check_access_rule("write")
|
||||
self.env.flush_all()
|
||||
self.env.cr.execute(
|
||||
"""
|
||||
UPDATE hr_timesheet_sheet
|
||||
SET state = 'draft',
|
||||
reviewer_id = NULL,
|
||||
write_uid = %s,
|
||||
write_date = (now() at time zone 'UTC')
|
||||
WHERE id IN %s
|
||||
""",
|
||||
(self.env.uid, tuple(sheets.ids)),
|
||||
)
|
||||
count = self.env.cr.rowcount
|
||||
self.env.invalidate_all(flush=False)
|
||||
_logger.info("Unfroze %s timesheet sheet(s).", count)
|
||||
return count
|
||||
3
hr_timesheet_sheet_no_review/readme/CONTRIBUTORS.rst
Normal file
3
hr_timesheet_sheet_no_review/readme/CONTRIBUTORS.rst
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
* Criptomart
|
||||
|
||||
* Luis Martín <luis@criptomart.net>
|
||||
20
hr_timesheet_sheet_no_review/readme/DESCRIPTION.rst
Normal file
20
hr_timesheet_sheet_no_review/readme/DESCRIPTION.rst
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
``hr_timesheet_sheet`` ships a review workflow: the employee submits the sheet,
|
||||
a reviewer approves or refuses it and may send it back to draft. Many
|
||||
organisations never use it — they record hours and do not submit them for
|
||||
approval.
|
||||
|
||||
The workflow is not harmless when it is not used. ``_check_state()`` forbids
|
||||
modifying the timesheet entries of any sheet that is not in ``new`` or
|
||||
``draft``. A sheet submitted by mistake and never attended freezes its entries
|
||||
forever: nobody can edit them, and ``action_timesheet_draft()`` only works from
|
||||
``done``, never from ``confirm``.
|
||||
|
||||
This module removes the review workflow from the user interface — buttons,
|
||||
kanban actions, search filters, the department counter of sheets to approve
|
||||
and the menus that only exist to serve it — and adds a wizard that sends the
|
||||
sheets that got stuck in ``confirm`` or ``done`` back to draft.
|
||||
|
||||
The ``action_timesheet_*`` methods of the base module are left untouched, so
|
||||
the rest of the family (``hr_timesheet_sheet_attendance``,
|
||||
``hr_timesheet_sheet_policy_*``, ``hr_timesheet_sheet_autodraft``) keeps
|
||||
working. They simply no longer have an entry point in the interface.
|
||||
31
hr_timesheet_sheet_no_review/readme/ROADMAP.rst
Normal file
31
hr_timesheet_sheet_no_review/readme/ROADMAP.rst
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
Hiding the review workflow is not the same as forbidding it. The
|
||||
``action_timesheet_*`` methods stay callable from code, from a server action or
|
||||
from another module, so a sheet can still go back to ``confirm`` through a
|
||||
non-interactive path. That is deliberate, for compatibility with the rest of the
|
||||
family. An installation that needs to really forbid it would need a separate
|
||||
module.
|
||||
|
||||
The modules of the family that take the review workflow for granted lose their
|
||||
purpose with this one installed. They do not break, because the methods are
|
||||
still there, but they become unreachable from the user interface:
|
||||
|
||||
* ``hr_timesheet_sheet_policy_department_manager``
|
||||
* ``hr_timesheet_sheet_policy_project_manager``
|
||||
* ``hr_timesheet_sheet_autodraft``
|
||||
* the ``action_timesheet_confirm`` check of ``hr_timesheet_sheet_attendance``
|
||||
|
||||
Unfreezing cannot be undone. Uninstalling the module brings the buttons back,
|
||||
but the sheets that were already set back to draft stay in draft, and the four
|
||||
menus of the review workflow stay deactivated: they belong to the base module
|
||||
and Odoo does not restore a value written on a foreign record.
|
||||
|
||||
The ``Timesheets to Review`` action of the department kanban loses both of its
|
||||
entry points, but the action record itself survives, because
|
||||
``ir.actions.act_window`` has no ``active`` field to switch off. It is only
|
||||
reachable from Settings > Technical > Actions, and its stale
|
||||
``search_default_to_review`` context key no longer matches any filter.
|
||||
|
||||
With the sheets back in ``draft``, every save triggers the base module's
|
||||
``delete_empty_lines(True)``, which removes lines with no description and no
|
||||
hours. This is pre-existing behaviour, but it may prune historical empty
|
||||
entries the first time those sheets are opened.
|
||||
22
hr_timesheet_sheet_no_review/readme/USAGE.rst
Normal file
22
hr_timesheet_sheet_no_review/readme/USAGE.rst
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
Once installed, the review buttons are gone from the timesheet sheet form and
|
||||
kanban; the ``Submitted``, ``To Review`` and ``Reviewer`` entries are gone from
|
||||
the sheet search view; the ``Not Submitted`` filter is gone from the timesheet
|
||||
search view; the department kanban no longer counts the sheets to approve; and
|
||||
the ``To Review`` and ``Timesheets to Submit`` menus are deactivated.
|
||||
|
||||
To release the sheets that were left frozen by the review workflow, go to
|
||||
*Timesheets > Timesheets > Unfreeze Pending Sheets*. The wizard first shows how
|
||||
many sheets, how many timesheet entries and how many hours are about to be
|
||||
released. Confirming sets those sheets back to draft and clears their reviewer.
|
||||
|
||||
Installing the module never changes the state of a sheet on its own: unfreezing
|
||||
is always an explicit decision of the timesheet manager. Running the wizard
|
||||
twice is safe — the second run reports zero sheets and changes nothing.
|
||||
|
||||
Take a backup before the first run: the operation touches every frozen sheet at
|
||||
once and the reviewer of the approved ones is lost.
|
||||
|
||||
To decide which sheets count as frozen, override
|
||||
``hr_timesheet.sheet._unfreeze_domain()``. For instance, an installation that
|
||||
wants to keep its approved sheets as historical record can narrow it down to
|
||||
``[("state", "=", "confirm")]``.
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_hr_timesheet_sheet_unfreeze_wizard,hr_timesheet.sheet.unfreeze.wizard,model_hr_timesheet_sheet_unfreeze_wizard,hr_timesheet.group_timesheet_manager,1,1,1,1
|
||||
|
1
hr_timesheet_sheet_no_review/tests/__init__.py
Normal file
1
hr_timesheet_sheet_no_review/tests/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import test_hr_timesheet_sheet_no_review
|
||||
|
|
@ -0,0 +1,358 @@
|
|||
# Copyright 2026 Criptomart
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
|
||||
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
from lxml import etree
|
||||
|
||||
from odoo.exceptions import AccessError, UserError
|
||||
from odoo.modules.module import get_manifest, get_module_path
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
MODULE = "hr_timesheet_sheet_no_review"
|
||||
REVIEW_ACTIONS = [
|
||||
"action_timesheet_confirm",
|
||||
"action_timesheet_done",
|
||||
"action_timesheet_draft",
|
||||
"action_timesheet_refuse",
|
||||
]
|
||||
REVIEW_MENUS = [
|
||||
"hr_timesheet_sheet.menu_hr_to_review",
|
||||
"hr_timesheet_sheet.menu_act_hr_timesheet_sheet_to_review",
|
||||
"hr_timesheet_sheet.menu_act_hr_timesheet_line_to_submit",
|
||||
"hr_timesheet_sheet.menu_act_hr_timesheet_line_to_submit_my",
|
||||
]
|
||||
|
||||
|
||||
class TestHrTimesheetSheetNoReview(TransactionCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.sheet_model = cls.env["hr_timesheet.sheet"]
|
||||
cls.line_model = cls.env["account.analytic.line"]
|
||||
cls.wizard_model = cls.env["hr_timesheet.sheet.unfreeze.wizard"]
|
||||
|
||||
cls.company = cls.env["res.company"].create({"name": "No Review Company"})
|
||||
cls.env.user.company_ids += cls.company
|
||||
|
||||
cls.user = (
|
||||
cls.env["res.users"]
|
||||
.with_context(no_reset_password=True, default_role_line_ids=[])
|
||||
.create(
|
||||
{
|
||||
"name": "No Review Employee",
|
||||
"login": "no_review_employee",
|
||||
"email": "no_review_employee@example.org",
|
||||
"company_id": cls.company.id,
|
||||
"company_ids": [(6, 0, [cls.company.id])],
|
||||
"groups_id": [
|
||||
(
|
||||
6,
|
||||
0,
|
||||
[
|
||||
cls.env.ref("hr_timesheet.group_hr_timesheet_user").id,
|
||||
cls.env.ref("project.group_project_user").id,
|
||||
],
|
||||
)
|
||||
],
|
||||
}
|
||||
)
|
||||
)
|
||||
cls.manager = (
|
||||
cls.env["res.users"]
|
||||
.with_context(no_reset_password=True, default_role_line_ids=[])
|
||||
.create(
|
||||
{
|
||||
"name": "No Review Manager",
|
||||
"login": "no_review_manager",
|
||||
"email": "no_review_manager@example.org",
|
||||
"company_id": cls.company.id,
|
||||
"company_ids": [(6, 0, [cls.company.id])],
|
||||
"groups_id": [
|
||||
(
|
||||
6,
|
||||
0,
|
||||
[
|
||||
cls.env.ref("hr_timesheet.group_timesheet_manager").id,
|
||||
cls.env.ref("project.group_project_manager").id,
|
||||
],
|
||||
)
|
||||
],
|
||||
}
|
||||
)
|
||||
)
|
||||
cls.reviewer = (
|
||||
cls.env["res.users"]
|
||||
.with_context(no_reset_password=True, default_role_line_ids=[])
|
||||
.create(
|
||||
{
|
||||
"name": "No Review Reviewer",
|
||||
"login": "no_review_reviewer",
|
||||
"email": "no_review_reviewer@example.org",
|
||||
"company_id": cls.company.id,
|
||||
"company_ids": [(6, 0, [cls.company.id])],
|
||||
"groups_id": [
|
||||
(
|
||||
6,
|
||||
0,
|
||||
[
|
||||
cls.env.ref("hr.group_hr_user").id,
|
||||
cls.env.ref("hr_timesheet.group_hr_timesheet_user").id,
|
||||
cls.env.ref("project.group_project_user").id,
|
||||
],
|
||||
)
|
||||
],
|
||||
}
|
||||
)
|
||||
)
|
||||
cls.employee = cls.env["hr.employee"].create(
|
||||
{
|
||||
"name": "No Review Employee",
|
||||
"user_id": cls.user.id,
|
||||
"company_id": cls.company.id,
|
||||
}
|
||||
)
|
||||
cls.env["hr.employee"].create(
|
||||
{
|
||||
"name": "No Review Reviewer",
|
||||
"user_id": cls.reviewer.id,
|
||||
"company_id": cls.company.id,
|
||||
}
|
||||
)
|
||||
cls.project = cls.env["project.project"].create(
|
||||
{
|
||||
"name": "No Review Project",
|
||||
"company_id": cls.company.id,
|
||||
"allow_timesheets": True,
|
||||
}
|
||||
)
|
||||
cls.task = cls.env["project.task"].create(
|
||||
{
|
||||
"name": "No Review Task",
|
||||
"project_id": cls.project.id,
|
||||
"company_id": cls.company.id,
|
||||
}
|
||||
)
|
||||
|
||||
cls.baseline = cls._frozen_figures(cls.env)
|
||||
|
||||
cls.sheet = cls.sheet_model.create(
|
||||
{"employee_id": cls.employee.id, "company_id": cls.company.id}
|
||||
)
|
||||
cls.line = cls.line_model.create(
|
||||
{
|
||||
"name": "Frozen entry",
|
||||
"date": cls.sheet.date_start,
|
||||
"project_id": cls.project.id,
|
||||
"task_id": cls.task.id,
|
||||
"employee_id": cls.employee.id,
|
||||
"company_id": cls.company.id,
|
||||
"unit_amount": 3.0,
|
||||
}
|
||||
)
|
||||
cls.sheet.action_timesheet_confirm()
|
||||
|
||||
@staticmethod
|
||||
def _frozen_figures(env):
|
||||
sheet_model = env["hr_timesheet.sheet"]
|
||||
sheets = sheet_model.search(sheet_model._unfreeze_domain())
|
||||
groups = env["account.analytic.line"].read_group(
|
||||
[("sheet_id", "in", sheets.ids)], ["unit_amount"], []
|
||||
)
|
||||
return {
|
||||
"sheets": len(sheets),
|
||||
"timesheets": groups[0]["__count"] if groups else 0,
|
||||
"hours": (groups[0]["unit_amount"] if groups else 0.0) or 0.0,
|
||||
}
|
||||
|
||||
def _arch(self, xml_id, view_type, model="hr_timesheet.sheet"):
|
||||
view = self.env.ref(xml_id)
|
||||
return etree.fromstring(
|
||||
self.env[model].get_view(view_id=view.id, view_type=view_type)["arch"]
|
||||
)
|
||||
|
||||
# RF-01
|
||||
|
||||
def test_review_buttons_are_hidden(self):
|
||||
for xml_id in (
|
||||
"hr_timesheet_sheet.hr_timesheet_sheet_form",
|
||||
"hr_timesheet_sheet.hr_timesheet_sheet_form_my",
|
||||
):
|
||||
arch = self._arch(xml_id, "form")
|
||||
for action in REVIEW_ACTIONS:
|
||||
self.assertFalse(
|
||||
arch.xpath("//button[@name='%s']" % action),
|
||||
"%s still exposes %s" % (xml_id, action),
|
||||
)
|
||||
state = arch.xpath("//header/field[@name='state']")
|
||||
self.assertTrue(state)
|
||||
self.assertEqual(state[0].get("statusbar_visible"), "draft")
|
||||
|
||||
def test_review_buttons_are_hidden_in_kanban(self):
|
||||
arch = self._arch("hr_timesheet_sheet.view_hr_timesheet_sheet_kanban", "kanban")
|
||||
for action in REVIEW_ACTIONS:
|
||||
self.assertFalse(arch.xpath("//a[@name='%s']" % action))
|
||||
|
||||
def test_reviewer_is_hidden_in_lists(self):
|
||||
for xml_id in (
|
||||
"hr_timesheet_sheet.hr_timesheet_sheet_tree",
|
||||
"hr_timesheet_sheet.hr_timesheet_sheet_tree_my",
|
||||
):
|
||||
arch = self._arch(xml_id, "tree")
|
||||
self.assertFalse(arch.xpath("//field[@name='reviewer_id']"))
|
||||
self.assertIsNone(arch.xpath("//tree")[0].get("decoration-muted"))
|
||||
|
||||
def test_review_filters_are_hidden_in_search(self):
|
||||
arch = self._arch("hr_timesheet_sheet.view_hr_timesheet_sheet_filter", "search")
|
||||
self.assertFalse(arch.xpath("//filter[@name='confirm']"))
|
||||
self.assertFalse(arch.xpath("//filter[@name='to_review']"))
|
||||
self.assertFalse(arch.xpath("//search/field[@name='reviewer_id']"))
|
||||
self.assertFalse(arch.xpath("//filter[@name='groupby_reviewer']"))
|
||||
|
||||
def test_submission_filter_is_hidden_in_timesheet_search(self):
|
||||
arch = self._arch(
|
||||
"hr_timesheet.hr_timesheet_line_search",
|
||||
"search",
|
||||
model="account.analytic.line",
|
||||
)
|
||||
self.assertFalse(arch.xpath("//filter[@name='unsubmitted']"))
|
||||
self.assertFalse(
|
||||
arch.xpath("//search/separator[not(preceding-sibling::filter)]"),
|
||||
"removing the filter must not leave its separator behind",
|
||||
)
|
||||
|
||||
def test_department_kanban_has_no_review_counter(self):
|
||||
arch = self._arch(
|
||||
"hr.hr_department_view_kanban", "kanban", model="hr.department"
|
||||
)
|
||||
rendered = etree.tostring(arch).decode()
|
||||
self.assertNotIn("timesheet_sheet_to_approve_count", rendered)
|
||||
action = self.env.ref("hr_timesheet_sheet.hr_timesheet_action_from_department")
|
||||
self.assertFalse(arch.xpath("//a[@name='%s']" % action.id))
|
||||
|
||||
def test_review_menus_are_inactive(self):
|
||||
for xml_id in REVIEW_MENUS:
|
||||
self.assertFalse(self.env.ref(xml_id).active, xml_id)
|
||||
|
||||
def test_review_methods_are_kept(self):
|
||||
"""The workflow is gone from the interface but still works from code.
|
||||
|
||||
The rest of the family (``..._policy_*``, ``..._autodraft``,
|
||||
``..._attendance``) calls these methods, so they must stay functional.
|
||||
"""
|
||||
sheet = self.sheet.with_user(self.reviewer)
|
||||
self.assertEqual(sheet.state, "confirm")
|
||||
|
||||
sheet.action_timesheet_done()
|
||||
self.assertEqual(sheet.state, "done")
|
||||
self.assertEqual(sheet.reviewer_id.user_id, self.reviewer)
|
||||
|
||||
sheet.action_timesheet_draft()
|
||||
self.assertEqual(sheet.state, "draft")
|
||||
self.assertFalse(sheet.reviewer_id)
|
||||
|
||||
self.sheet.with_user(self.user).action_timesheet_confirm()
|
||||
self.assertEqual(sheet.state, "confirm")
|
||||
|
||||
sheet.action_timesheet_refuse()
|
||||
self.assertEqual(sheet.state, "draft")
|
||||
self.assertFalse(sheet.reviewer_id)
|
||||
|
||||
# RF-02
|
||||
|
||||
def test_install_does_not_change_sheet_states(self):
|
||||
manifest = get_manifest(MODULE)
|
||||
for key in ("pre_init_hook", "post_init_hook", "uninstall_hook", "post_load"):
|
||||
self.assertFalse(
|
||||
manifest.get(key), "%s must not declare a %s" % (MODULE, key)
|
||||
)
|
||||
module_path = get_module_path(MODULE)
|
||||
for data_file in manifest["data"]:
|
||||
with open(os.path.join(module_path, data_file)) as handle:
|
||||
self.assertNotRegex(
|
||||
handle.read(),
|
||||
r"<\s*function",
|
||||
"%s must not call a method on install" % data_file,
|
||||
)
|
||||
records = self.env["ir.model.data"].search([("module", "=", MODULE)])
|
||||
self.assertTrue(records)
|
||||
business_models = records.filtered(
|
||||
lambda record: not record.model.startswith("ir.")
|
||||
)
|
||||
self.assertFalse(
|
||||
business_models.mapped("model"),
|
||||
"%s must only install technical records" % MODULE,
|
||||
)
|
||||
|
||||
def test_wizard_reports_pending_sheets(self):
|
||||
wizard = self.wizard_model.create({})
|
||||
self.assertEqual(wizard.sheet_count, self.baseline["sheets"] + 1)
|
||||
self.assertEqual(wizard.timesheet_count, self.baseline["timesheets"] + 1)
|
||||
self.assertAlmostEqual(
|
||||
wizard.timesheet_hours, self.baseline["hours"] + 3.0, places=2
|
||||
)
|
||||
self.assertNotIn("Nothing to unfreeze", wizard.summary_html)
|
||||
|
||||
def test_wizard_reports_nothing_to_unfreeze(self):
|
||||
self.sheet_model._unfreeze_sheets()
|
||||
wizard = self.wizard_model.create({})
|
||||
self.assertEqual(wizard.sheet_count, 0)
|
||||
self.assertEqual(wizard.timesheet_count, 0)
|
||||
self.assertEqual(wizard.timesheet_hours, 0.0)
|
||||
self.assertIn("Nothing to unfreeze", wizard.summary_html)
|
||||
|
||||
def test_unfreeze_releases_sheets(self):
|
||||
self.assertEqual(self.sheet.state, "confirm")
|
||||
wizard = self.wizard_model.create({})
|
||||
expected = wizard.sheet_count
|
||||
result = wizard.action_unfreeze()
|
||||
self.assertEqual(self.sheet.state, "draft")
|
||||
self.assertFalse(self.sheet.reviewer_id)
|
||||
self.assertEqual(
|
||||
self.sheet_model.search_count([("state", "in", ("confirm", "done"))]), 0
|
||||
)
|
||||
self.assertIn(str(expected), result["params"]["message"])
|
||||
|
||||
def test_unfreeze_is_idempotent(self):
|
||||
self.assertTrue(self.sheet_model._unfreeze_sheets())
|
||||
self.assertEqual(self.sheet_model._unfreeze_sheets(), 0)
|
||||
wizard = self.wizard_model.create({})
|
||||
result = wizard.action_unfreeze()
|
||||
self.assertEqual(
|
||||
self.sheet_model.search_count([("state", "in", ("confirm", "done"))]), 0
|
||||
)
|
||||
self.assertEqual(result["params"]["type"], "info")
|
||||
self.assertEqual(self.sheet.state, "draft")
|
||||
|
||||
def test_unfrozen_lines_are_editable(self):
|
||||
with self.assertRaises(UserError):
|
||||
self.line.with_user(self.user).write({"unit_amount": 5.0})
|
||||
self.wizard_model.create({}).action_unfreeze()
|
||||
self.line.with_user(self.user).write({"unit_amount": 5.0})
|
||||
self.assertEqual(self.line.unit_amount, 5.0)
|
||||
|
||||
def test_unfreeze_domain_hook(self):
|
||||
self.assertEqual(
|
||||
self.sheet_model._unfreeze_domain(),
|
||||
[("state", "in", ("confirm", "done"))],
|
||||
)
|
||||
kept_id = self.sheet.id
|
||||
with patch.object(
|
||||
type(self.sheet_model),
|
||||
"_unfreeze_domain",
|
||||
lambda records: [
|
||||
("state", "in", ("confirm", "done")),
|
||||
("id", "!=", kept_id),
|
||||
],
|
||||
):
|
||||
self.sheet_model._unfreeze_sheets()
|
||||
self.assertEqual(self.sheet.state, "confirm")
|
||||
self.assertEqual(
|
||||
self.sheet_model.search_count([("state", "in", ("confirm", "done"))]), 1
|
||||
)
|
||||
|
||||
def test_wizard_is_restricted_to_managers(self):
|
||||
with self.assertRaises(AccessError):
|
||||
self.wizard_model.with_user(self.user).create({})
|
||||
self.assertTrue(self.wizard_model.with_user(self.manager).create({}))
|
||||
103
hr_timesheet_sheet_no_review/views/hr_timesheet_sheet_views.xml
Normal file
103
hr_timesheet_sheet_no_review/views/hr_timesheet_sheet_views.xml
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<!--
|
||||
Copyright 2026 Criptomart
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
|
||||
-->
|
||||
<odoo>
|
||||
<record id="hr_timesheet_sheet_form" model="ir.ui.view">
|
||||
<field name="name">hr_timesheet.sheet.form.no.review</field>
|
||||
<field name="model">hr_timesheet.sheet</field>
|
||||
<field name="inherit_id" ref="hr_timesheet_sheet.hr_timesheet_sheet_form" />
|
||||
<field name="arch" type="xml">
|
||||
<xpath
|
||||
expr="//header/button[@name='action_timesheet_confirm']"
|
||||
position="replace"
|
||||
/>
|
||||
<xpath
|
||||
expr="//header/button[@name='action_timesheet_done']"
|
||||
position="replace"
|
||||
/>
|
||||
<xpath
|
||||
expr="//header/button[@name='action_timesheet_draft']"
|
||||
position="replace"
|
||||
/>
|
||||
<xpath
|
||||
expr="//header/button[@name='action_timesheet_refuse']"
|
||||
position="replace"
|
||||
/>
|
||||
<xpath expr="//header/field[@name='state']" position="attributes">
|
||||
<attribute name="statusbar_visible">draft</attribute>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
<record id="view_hr_timesheet_sheet_kanban" model="ir.ui.view">
|
||||
<field name="name">hr_timesheet.sheet.kanban.no.review</field>
|
||||
<field name="model">hr_timesheet.sheet</field>
|
||||
<field
|
||||
name="inherit_id"
|
||||
ref="hr_timesheet_sheet.view_hr_timesheet_sheet_kanban"
|
||||
/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//a[@name='action_timesheet_confirm']" position="replace" />
|
||||
<xpath expr="//a[@name='action_timesheet_done']" position="replace" />
|
||||
<xpath expr="//a[@name='action_timesheet_refuse']" position="replace" />
|
||||
<xpath expr="//a[@name='action_timesheet_draft']" position="replace" />
|
||||
</field>
|
||||
</record>
|
||||
<record id="hr_timesheet_sheet_tree" model="ir.ui.view">
|
||||
<field name="name">hr_timesheet.sheet.tree.no.review</field>
|
||||
<field name="model">hr_timesheet.sheet</field>
|
||||
<field name="inherit_id" ref="hr_timesheet_sheet.hr_timesheet_sheet_tree" />
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//tree/field[@name='reviewer_id']" position="replace" />
|
||||
<xpath expr="//tree" position="attributes">
|
||||
<attribute name="decoration-muted" />
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
<record id="view_hr_timesheet_sheet_filter" model="ir.ui.view">
|
||||
<field name="name">hr_timesheet.sheet.filter.no.review</field>
|
||||
<field name="model">hr_timesheet.sheet</field>
|
||||
<field
|
||||
name="inherit_id"
|
||||
ref="hr_timesheet_sheet.view_hr_timesheet_sheet_filter"
|
||||
/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//search/filter[@name='confirm']" position="replace" />
|
||||
<xpath expr="//search/filter[@name='to_review']" position="replace" />
|
||||
<xpath expr="//search/field[@name='reviewer_id']" position="replace" />
|
||||
<xpath expr="//filter[@name='groupby_reviewer']" position="replace" />
|
||||
</field>
|
||||
</record>
|
||||
<record id="hr_timesheet_line_search" model="ir.ui.view">
|
||||
<field name="name">account.analytic.line.search.no.review</field>
|
||||
<field name="model">account.analytic.line</field>
|
||||
<field name="inherit_id" ref="hr_timesheet_sheet.hr_timesheet_line_search" />
|
||||
<field name="arch" type="xml">
|
||||
<xpath
|
||||
expr="//search/filter[@name='unsubmitted']/following-sibling::separator[1]"
|
||||
position="replace"
|
||||
/>
|
||||
<xpath expr="//search/filter[@name='unsubmitted']" position="replace" />
|
||||
</field>
|
||||
</record>
|
||||
<record id="hr_department_view_kanban" model="ir.ui.view">
|
||||
<field name="name">hr.department.kanban.no.review</field>
|
||||
<field name="model">hr.department</field>
|
||||
<field name="inherit_id" ref="hr_timesheet_sheet.hr_department_view_kanban" />
|
||||
<field name="arch" type="xml">
|
||||
<xpath
|
||||
expr="//field[@name='timesheet_sheet_to_approve_count']"
|
||||
position="replace"
|
||||
/>
|
||||
<xpath
|
||||
expr="//div[starts-with(@t-if, 'record.timesheet_sheet_to_approve_count')]"
|
||||
position="replace"
|
||||
/>
|
||||
<xpath
|
||||
expr="//div[hasclass('o_kanban_manage_reports')]/div[.//t[starts-with(@t-esc, 'record.timesheet_sheet_to_approve')]]"
|
||||
position="replace"
|
||||
/>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
35
hr_timesheet_sheet_no_review/views/menus.xml
Normal file
35
hr_timesheet_sheet_no_review/views/menus.xml
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<!--
|
||||
Copyright 2026 Criptomart
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
|
||||
-->
|
||||
<odoo>
|
||||
<record id="hr_timesheet_sheet.menu_hr_to_review" model="ir.ui.menu">
|
||||
<field name="active" eval="False" />
|
||||
</record>
|
||||
<record
|
||||
id="hr_timesheet_sheet.menu_act_hr_timesheet_sheet_to_review"
|
||||
model="ir.ui.menu"
|
||||
>
|
||||
<field name="active" eval="False" />
|
||||
</record>
|
||||
<record
|
||||
id="hr_timesheet_sheet.menu_act_hr_timesheet_line_to_submit"
|
||||
model="ir.ui.menu"
|
||||
>
|
||||
<field name="active" eval="False" />
|
||||
</record>
|
||||
<record
|
||||
id="hr_timesheet_sheet.menu_act_hr_timesheet_line_to_submit_my"
|
||||
model="ir.ui.menu"
|
||||
>
|
||||
<field name="active" eval="False" />
|
||||
</record>
|
||||
<menuitem
|
||||
id="menu_hr_timesheet_sheet_unfreeze"
|
||||
action="action_hr_timesheet_sheet_unfreeze_wizard"
|
||||
parent="hr_timesheet.menu_hr_time_tracking"
|
||||
groups="hr_timesheet.group_timesheet_manager"
|
||||
sequence="99"
|
||||
/>
|
||||
</odoo>
|
||||
1
hr_timesheet_sheet_no_review/wizards/__init__.py
Normal file
1
hr_timesheet_sheet_no_review/wizards/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import unfreeze_wizard
|
||||
79
hr_timesheet_sheet_no_review/wizards/unfreeze_wizard.py
Normal file
79
hr_timesheet_sheet_no_review/wizards/unfreeze_wizard.py
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
# Copyright 2026 Criptomart
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
|
||||
|
||||
from markupsafe import Markup
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
|
||||
|
||||
class HrTimesheetSheetUnfreezeWizard(models.TransientModel):
|
||||
_name = "hr_timesheet.sheet.unfreeze.wizard"
|
||||
_description = "Unfreeze Pending Timesheet Sheets"
|
||||
|
||||
sheet_count = fields.Integer(
|
||||
string="Frozen Sheets",
|
||||
compute="_compute_frozen_data",
|
||||
)
|
||||
timesheet_count = fields.Integer(
|
||||
string="Blocked Timesheets",
|
||||
compute="_compute_frozen_data",
|
||||
)
|
||||
timesheet_hours = fields.Float(
|
||||
string="Blocked Hours",
|
||||
compute="_compute_frozen_data",
|
||||
)
|
||||
summary_html = fields.Html(
|
||||
string="Summary",
|
||||
compute="_compute_summary_html",
|
||||
)
|
||||
|
||||
# No @api.depends: the figures come from the state of the whole database,
|
||||
# not from any field of the wizard. The wizard is created anew every time
|
||||
# the action is opened, so they are computed once, when the dialog is read.
|
||||
def _compute_frozen_data(self):
|
||||
sheet_model = self.env["hr_timesheet.sheet"]
|
||||
sheets = sheet_model.search(sheet_model._unfreeze_domain())
|
||||
groups = self.env["account.analytic.line"].read_group(
|
||||
[("sheet_id", "in", sheets.ids)], ["unit_amount"], []
|
||||
)
|
||||
timesheet_count = groups[0]["__count"] if groups else 0
|
||||
timesheet_hours = (groups[0]["unit_amount"] if groups else 0.0) or 0.0
|
||||
for wizard in self:
|
||||
wizard.sheet_count = len(sheets)
|
||||
wizard.timesheet_count = timesheet_count
|
||||
wizard.timesheet_hours = timesheet_hours
|
||||
|
||||
@api.depends("sheet_count", "timesheet_count", "timesheet_hours")
|
||||
def _compute_summary_html(self):
|
||||
for wizard in self:
|
||||
wizard.summary_html = wizard._build_summary_html()
|
||||
|
||||
def _build_summary_html(self):
|
||||
self.ensure_one()
|
||||
if not self.sheet_count:
|
||||
return Markup("<p>%s</p>") % _(
|
||||
"There is no frozen timesheet sheet. Nothing to unfreeze."
|
||||
)
|
||||
return Markup("<p>%s</p>") % _(
|
||||
"The timesheet sheets frozen by the review workflow, and the "
|
||||
"timesheet entries they keep locked, are:"
|
||||
)
|
||||
|
||||
def action_unfreeze(self):
|
||||
self.ensure_one()
|
||||
count = self.env["hr_timesheet.sheet"]._unfreeze_sheets()
|
||||
if count:
|
||||
message = _("%s timesheet sheet(s) have been set back to draft.", count)
|
||||
else:
|
||||
message = _("No frozen timesheet sheet was found. Nothing was changed.")
|
||||
return {
|
||||
"type": "ir.actions.client",
|
||||
"tag": "display_notification",
|
||||
"params": {
|
||||
"title": _("Unfreeze Pending Sheets"),
|
||||
"message": message,
|
||||
"type": "success" if count else "info",
|
||||
"sticky": False,
|
||||
"next": {"type": "ir.actions.act_window_close"},
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<!--
|
||||
Copyright 2026 Criptomart
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
|
||||
-->
|
||||
<odoo>
|
||||
<record id="hr_timesheet_sheet_unfreeze_wizard_form" model="ir.ui.view">
|
||||
<field name="name">hr_timesheet.sheet.unfreeze.wizard.form</field>
|
||||
<field name="model">hr_timesheet.sheet.unfreeze.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Unfreeze Pending Sheets">
|
||||
<field name="summary_html" nolabel="1" readonly="1" />
|
||||
<group attrs="{'invisible': [('sheet_count', '=', 0)]}">
|
||||
<field name="sheet_count" />
|
||||
<field name="timesheet_count" />
|
||||
<field name="timesheet_hours" widget="float_time" />
|
||||
</group>
|
||||
<div
|
||||
class="alert alert-warning"
|
||||
role="alert"
|
||||
attrs="{'invisible': [('sheet_count', '=', 0)]}"
|
||||
>
|
||||
This is a mass operation on historical data and it cannot be
|
||||
undone: the reviewer of every approved sheet is cleared. Make
|
||||
sure you have a recent backup before you continue.
|
||||
</div>
|
||||
<footer>
|
||||
<button
|
||||
name="action_unfreeze"
|
||||
string="Unfreeze"
|
||||
type="object"
|
||||
class="btn-primary"
|
||||
attrs="{'invisible': [('sheet_count', '=', 0)]}"
|
||||
/>
|
||||
<button string="Cancel" class="btn-secondary" special="cancel" />
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
<record
|
||||
id="action_hr_timesheet_sheet_unfreeze_wizard"
|
||||
model="ir.actions.act_window"
|
||||
>
|
||||
<field name="name">Unfreeze Pending Sheets</field>
|
||||
<field name="res_model">hr_timesheet.sheet.unfreeze.wizard</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="view_id" ref="hr_timesheet_sheet_unfreeze_wizard_form" />
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
</odoo>
|
||||
Loading…
Add table
Add a link
Reference in a new issue