Compare commits
28 commits
18.0-ci-fo
...
16.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 3be3de173e | |||
| 3c5809774f | |||
| 9d69e5f095 | |||
| 0323b6672e | |||
| 8b0d0bb6cc | |||
| b07082f70d | |||
| 10a288f8d5 | |||
| 99dc5f3848 | |||
| 08e34c1426 | |||
| 0f2c8c0565 | |||
| 5247938b86 | |||
| 86101218e9 | |||
| d994ab600e | |||
| b7e8f8967c | |||
| 9b25650118 | |||
| 7f43afafdf | |||
| 611979ea2e | |||
| c76254e3c7 | |||
| 489eefefc7 | |||
| f775050ea8 | |||
| 524e3d643c | |||
| 91971ae56b | |||
| f586c99df9 | |||
| d9a0eeb878 | |||
| bf02699b61 | |||
| dab4123379 | |||
| 25faab83bb | |||
| a1f73dabe7 |
290 changed files with 17260 additions and 1164 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()
|
||||
312
hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py
Normal file
312
hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
# Copyright 2026 Criptomart
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
|
||||
|
||||
from odoo import SUPERUSER_ID, _, models
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tools.misc import format_date, format_duration
|
||||
|
||||
REFUSABLE_LEAVE_STATES = ("draft", "confirm", "validate", "validate1")
|
||||
|
||||
|
||||
class Sheet(models.Model):
|
||||
_inherit = "hr_timesheet.sheet"
|
||||
|
||||
def _sheet_delete_cascade_token(self):
|
||||
"""Hook for extensions"""
|
||||
return self.env["account.analytic.line"]._sheet_delete_cascade_token()
|
||||
|
||||
def _with_delete_cascade(self):
|
||||
"""Hook for extensions
|
||||
|
||||
The deliberate way to run the cascade from code. It is a private
|
||||
method on purpose: a name starting with an underscore cannot be
|
||||
reached through call_kw, so the sentinel cannot be obtained remotely.
|
||||
"""
|
||||
token = self._sheet_delete_cascade_token()
|
||||
return self.with_context(sheet_delete_cascade=token)
|
||||
|
||||
def _in_delete_cascade(self):
|
||||
return (
|
||||
self.env.context.get("sheet_delete_cascade")
|
||||
is self._sheet_delete_cascade_token()
|
||||
)
|
||||
|
||||
def _sheet_leave_policy(self):
|
||||
"""Hook for extensions
|
||||
|
||||
The policy in force for this recordset: ``cancel`` as soon as one
|
||||
company asks for it, so that a summary never understates what is about
|
||||
to happen. The destructive path works sheet by sheet, where the
|
||||
recordset holds a single sheet and the answer is exact.
|
||||
"""
|
||||
companies = self.company_id or self.env.company
|
||||
policies = companies.mapped("timesheet_sheet_delete_leave_policy")
|
||||
return "cancel" if "cancel" in policies else "detach"
|
||||
|
||||
def _delete_cascade_cancel_sheets(self):
|
||||
"""Hook for extensions"""
|
||||
return self.filtered(lambda sheet: sheet._sheet_leave_policy() == "cancel")
|
||||
|
||||
def _sheet_leave_lines(self):
|
||||
"""Hook for extensions"""
|
||||
return self.mapped("timesheet_ids").filtered("holiday_id")
|
||||
|
||||
def _sheet_leaves(self):
|
||||
"""Hook for extensions
|
||||
|
||||
Read in sudo: a timesheet manager entitled to delete the sheet of
|
||||
another employee is not necessarily entitled to read that employee's
|
||||
time off, and the confirmation wizard has to describe it. Only reached
|
||||
with the ``cancel`` policy; the ``detach`` policy never reads hr.leave.
|
||||
"""
|
||||
return self._sheet_leave_lines().holiday_id.sudo()
|
||||
|
||||
def _sheet_leaves_to_cancel(self):
|
||||
"""Hook for extensions"""
|
||||
return self._sheet_leaves().filtered(
|
||||
lambda leave: leave.state in REFUSABLE_LEAVE_STATES
|
||||
)
|
||||
|
||||
def _sheet_leaves_not_cancellable(self):
|
||||
"""Hook for extensions"""
|
||||
return self._sheet_leaves().filtered(
|
||||
lambda leave: leave.state not in REFUSABLE_LEAVE_STATES
|
||||
)
|
||||
|
||||
def _sheet_leaves_beyond_period(self):
|
||||
"""Hook for extensions"""
|
||||
sheet_lines = self.mapped("timesheet_ids")
|
||||
leaves = self._sheet_leaves_to_cancel()
|
||||
return leaves.filtered(lambda leave: bool(leave.timesheet_ids - sheet_lines))
|
||||
|
||||
def _delete_cascade_blocking(self):
|
||||
"""Hook for extensions"""
|
||||
reasons = []
|
||||
for sheet in self:
|
||||
invoiced = sheet.timesheet_ids.filtered("timesheet_invoice_id")
|
||||
if invoiced:
|
||||
reasons.append(
|
||||
_(
|
||||
"Sheet %(sheet)s contains %(count)s invoiced timesheet"
|
||||
" entries, which can never be deleted.",
|
||||
sheet=sheet.complete_name,
|
||||
count=len(invoiced),
|
||||
)
|
||||
)
|
||||
if sheet.state in ("confirm", "done"):
|
||||
reasons.append(
|
||||
_(
|
||||
"Sheet %(sheet)s is already submitted or approved and"
|
||||
" cannot be deleted.",
|
||||
sheet=sheet.complete_name,
|
||||
)
|
||||
)
|
||||
if sheet._sheet_leave_policy() != "cancel":
|
||||
continue
|
||||
stuck = sheet._sheet_leaves_not_cancellable()
|
||||
if stuck:
|
||||
reasons.append(
|
||||
_(
|
||||
"Sheet %(sheet)s contains entries of time off that"
|
||||
" cannot be cancelled any more: %(leaves)s. Remove"
|
||||
" them from the Time Off application first.",
|
||||
sheet=sheet.complete_name,
|
||||
leaves=", ".join(
|
||||
"%s (%s)" % (leave.display_name, leave.state)
|
||||
for leave in stuck
|
||||
),
|
||||
)
|
||||
)
|
||||
return reasons
|
||||
|
||||
def _delete_cascade_check(self):
|
||||
"""Hook for extensions"""
|
||||
reasons = self._delete_cascade_blocking()
|
||||
if reasons:
|
||||
raise UserError("\n".join(reasons))
|
||||
|
||||
def _delete_cascade_detach_section(self):
|
||||
"""Hook for extensions"""
|
||||
leave_lines = self._sheet_leave_lines()
|
||||
if not leave_lines:
|
||||
return None
|
||||
return {
|
||||
"title": _("Time off"),
|
||||
"lines": [
|
||||
_(
|
||||
"%s time off entries will be detached from the sheet and"
|
||||
" will survive the deletion.",
|
||||
len(leave_lines),
|
||||
),
|
||||
_(
|
||||
"The time off requests themselves are not modified and the"
|
||||
" employee balances do not change."
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
def _delete_cascade_cancel_section(self):
|
||||
"""Hook for extensions"""
|
||||
leaves = self._sheet_leaves_to_cancel()
|
||||
if not self._sheet_leave_lines():
|
||||
return None, 0.0
|
||||
lines = [
|
||||
_(
|
||||
"%(employee)s - %(type)s (%(date_from)s - %(date_to)s):"
|
||||
" %(days)s days",
|
||||
employee=leave.employee_id.name,
|
||||
type=leave.holiday_status_id.name,
|
||||
date_from=format_date(self.env, leave.date_from),
|
||||
date_to=format_date(self.env, leave.date_to),
|
||||
days=leave.number_of_days,
|
||||
)
|
||||
for leave in leaves
|
||||
]
|
||||
leave_days = sum(leaves.mapped("number_of_days"))
|
||||
lines.append(
|
||||
_("%s days will be restored to the employee balances.", leave_days)
|
||||
)
|
||||
beyond = self._sheet_leaves_beyond_period()
|
||||
if beyond:
|
||||
lines.append(
|
||||
_(
|
||||
"Warning: the following time off requests also cover days"
|
||||
" outside the period of the deleted sheets, and they will"
|
||||
" be cancelled in full: %s",
|
||||
", ".join(
|
||||
"%s (%s - %s)"
|
||||
% (
|
||||
leave.employee_id.name,
|
||||
format_date(self.env, leave.date_from),
|
||||
format_date(self.env, leave.date_to),
|
||||
)
|
||||
for leave in beyond
|
||||
),
|
||||
)
|
||||
)
|
||||
return {"title": _("Time off to cancel"), "lines": lines}, leave_days
|
||||
|
||||
def _delete_cascade_leave_sections(self):
|
||||
"""Hook for extensions
|
||||
|
||||
Built per policy rather than per batch: a selection may span companies
|
||||
that answer differently, and the summary has to describe what each
|
||||
sheet is really going to do.
|
||||
"""
|
||||
sections = []
|
||||
cancel_sheets = self._delete_cascade_cancel_sheets()
|
||||
detach_section = (self - cancel_sheets)._delete_cascade_detach_section()
|
||||
if detach_section:
|
||||
sections.append(detach_section)
|
||||
cancel_section, leave_days = cancel_sheets._delete_cascade_cancel_section()
|
||||
if cancel_section:
|
||||
sections.append(cancel_section)
|
||||
return sections, leave_days
|
||||
|
||||
def _delete_cascade_prepare(self):
|
||||
"""Hook for extensions"""
|
||||
lines = self.mapped("timesheet_ids")
|
||||
invoiced = lines.filtered("timesheet_invoice_id")
|
||||
hours = sum(lines.mapped("unit_amount"))
|
||||
sections = [
|
||||
{
|
||||
"title": _("Timesheet sheets to delete"),
|
||||
"lines": [sheet.complete_name for sheet in self],
|
||||
},
|
||||
{
|
||||
"title": _("Timesheet entries to delete"),
|
||||
"lines": [
|
||||
_(
|
||||
"%(count)s entries, %(hours)s hours in total.",
|
||||
count=len(lines),
|
||||
hours=format_duration(hours),
|
||||
)
|
||||
],
|
||||
},
|
||||
]
|
||||
leave_sections, leave_days = self._delete_cascade_leave_sections()
|
||||
sections.extend(leave_sections)
|
||||
return {
|
||||
"sheet_count": len(self),
|
||||
"timesheet_count": len(lines),
|
||||
"timesheet_hours": hours,
|
||||
"invoiced_count": len(invoiced),
|
||||
"leave_count": len(self._sheet_leave_lines()),
|
||||
"leave_days": leave_days,
|
||||
"leave_policy": self._sheet_leave_policy(),
|
||||
"blocking": self._delete_cascade_blocking(),
|
||||
"sections": sections,
|
||||
}
|
||||
|
||||
def _delete_cascade_cancel_leaves(self):
|
||||
"""Hook for extensions"""
|
||||
# sudo() is not enough here, and the extra elevation is deliberate.
|
||||
# sudo() flips env.su but keeps the uid, and hr.leave's
|
||||
# _unlink_if_correct_states gates the deletion on
|
||||
# user_has_groups('hr_holidays.group_hr_holidays_user'), which reads
|
||||
# the uid: without a Time Off officer behind it, deleting a time off
|
||||
# that already started raises. SUPERUSER_ID is the only way to run the
|
||||
# documented refuse/draft/unlink sequence as any employee.
|
||||
# It stays safe because it is confined to hr.leave, it only ever
|
||||
# reaches the requests found inside sheets the user is already allowed
|
||||
# to delete by the record rules of this module, and it is unreachable
|
||||
# unless the company policy is "cancel".
|
||||
leaves = self._sheet_leaves_to_cancel().with_user(SUPERUSER_ID).sudo()
|
||||
if not leaves:
|
||||
return
|
||||
leaves.action_refuse()
|
||||
leaves.action_draft()
|
||||
leaves.unlink()
|
||||
|
||||
def _delete_cascade_apply(self):
|
||||
"""Hook for extensions"""
|
||||
for sheet in self:
|
||||
if sheet._sheet_leave_policy() == "cancel":
|
||||
sheet._delete_cascade_cancel_leaves()
|
||||
# action_refuse() calls _check_missing_global_leave_timesheets(),
|
||||
# which can create public holiday entries that land in this very
|
||||
# sheet. Re-read the set so they are deleted with it instead of
|
||||
# being left behind pointing at a sheet that no longer exists.
|
||||
sheet.invalidate_recordset(["timesheet_ids"])
|
||||
sheet.timesheet_ids.exists().unlink()
|
||||
continue
|
||||
lines = sheet.timesheet_ids.exists()
|
||||
leave_lines = lines.filtered("holiday_id")
|
||||
if leave_lines:
|
||||
leave_lines.write({"sheet_id": False})
|
||||
other_lines = lines - leave_lines
|
||||
if other_lines:
|
||||
other_lines.unlink()
|
||||
|
||||
def unlink(self):
|
||||
if not self._in_delete_cascade():
|
||||
raise UserError(
|
||||
_(
|
||||
"Deleting a timesheet sheet also deletes its timesheet"
|
||||
" entries and cannot be undone. Use the Delete Sheet"
|
||||
" button on the sheet to review what will be deleted and"
|
||||
" confirm it."
|
||||
)
|
||||
)
|
||||
self._delete_cascade_check()
|
||||
self._delete_cascade_apply()
|
||||
return super().unlink()
|
||||
|
||||
def action_delete_sheet(self):
|
||||
return {
|
||||
"type": "ir.actions.act_window",
|
||||
"name": _("Delete Timesheet Sheet"),
|
||||
"res_model": "hr_timesheet.sheet.delete.wizard",
|
||||
"view_mode": "form",
|
||||
"target": "new",
|
||||
"context": dict(
|
||||
self.env.context,
|
||||
active_model="hr_timesheet.sheet",
|
||||
active_ids=self.ids,
|
||||
active_id=self.ids[0] if self.ids else False,
|
||||
# The button lives in the header of the sheet form: once the
|
||||
# sheet is gone that form cannot be restored, and the wizard
|
||||
# has to send the user somewhere else.
|
||||
sheet_delete_cascade_from_form=True,
|
||||
),
|
||||
}
|
||||
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.
|
||||
33
hr_timesheet_sheet_delete_cascade/readme/USAGE.rst
Normal file
33
hr_timesheet_sheet_delete_cascade/readme/USAGE.rst
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
The native trash bin is removed from the sheet form, list and kanban views.
|
||||
Sheets are deleted from the **Delete Sheet** button in the header of the sheet
|
||||
form, or from the **Action** menu of the list view for several sheets at once.
|
||||
|
||||
Both open a confirmation wizard that lists the sheets, the number of timesheet
|
||||
entries and the total number of hours that will be deleted, and a time off
|
||||
section that depends on the configured policy. Nothing is deleted until
|
||||
**Delete permanently** is pressed, and the button is hidden when something
|
||||
blocks the deletion.
|
||||
|
||||
Deleting from the form leaves the user on the list of timesheet sheets, since
|
||||
the form he was on shows a sheet that no longer exists: *My Timesheet Sheets*,
|
||||
or *All Timesheet Sheets* when a timesheet administrator deletes the sheet of
|
||||
another employee. The deleted sheet is not kept in the breadcrumb. Deleting
|
||||
several sheets from the **Action** menu of a list leaves that list where it
|
||||
was, with its filters and its grouping.
|
||||
|
||||
A plain ``sheet.unlink()`` from a script or a server action raises a
|
||||
``UserError`` that points to the button, so the confirmation step cannot be
|
||||
skipped by accident. Code that really means to delete does
|
||||
``sheet._with_delete_cascade().unlink()``.
|
||||
|
||||
The cascade is armed by a sentinel object rather than by a plain value in the
|
||||
context, and ``_with_delete_cascade()`` is the only way to obtain it. The
|
||||
context of an RPC call is controlled by the caller, so a context flag holding
|
||||
a JSON value could be forged by any authenticated user and would delete a
|
||||
sheet without ever showing the confirmation; a sentinel compared by identity
|
||||
cannot be produced by a deserializer, and a method whose name starts with an
|
||||
underscore cannot be reached remotely.
|
||||
|
||||
With the ``Cancel the time off`` policy, cancelling a time off request posts a
|
||||
message in its chatter telling the employee that the request has been refused.
|
||||
The wording talks about a refusal, not about a deleted timesheet sheet.
|
||||
|
|
@ -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,811 @@
|
|||
# Copyright 2026 Criptomart
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
|
||||
|
||||
import json
|
||||
from datetime import date, datetime
|
||||
|
||||
from odoo import SUPERUSER_ID
|
||||
from odoo.api import call_kw
|
||||
from odoo.exceptions import AccessError, UserError
|
||||
from odoo.tests import tagged
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
WEEK_START = date(2026, 10, 5)
|
||||
WEEK_END = date(2026, 10, 11)
|
||||
|
||||
|
||||
@tagged("post_install", "-at_install")
|
||||
class TestHrTimesheetSheetDeleteCascade(TransactionCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.env = cls.env(
|
||||
context=dict(
|
||||
cls.env.context,
|
||||
tracking_disable=True,
|
||||
no_reset_password=True,
|
||||
mail_create_nolog=True,
|
||||
mail_notrack=True,
|
||||
)
|
||||
)
|
||||
cls.company = cls.env["res.company"].create({"name": "Delete Cascade Co"})
|
||||
cls.env.user.write({"company_ids": [(4, cls.company.id)]})
|
||||
cls.company.resource_calendar_id.tz = "UTC"
|
||||
cls.internal_project = cls.company.internal_project_id
|
||||
cls.timeoff_task = cls.company.leave_timesheet_task_id
|
||||
|
||||
cls.user = cls._new_user("tsdc_employee")
|
||||
cls.user_other = cls._new_user("tsdc_other")
|
||||
cls.user_span = cls._new_user("tsdc_span")
|
||||
cls.user_manager = cls._new_user(
|
||||
"tsdc_manager", group="hr_timesheet.group_timesheet_manager"
|
||||
)
|
||||
cls.user_internal = cls._new_internal_user("tsdc_internal")
|
||||
cls.employee = cls._new_employee("Cascade Employee", cls.user)
|
||||
cls.employee_other = cls._new_employee("Other Employee", cls.user_other)
|
||||
cls.employee_span = cls._new_employee("Span Employee", cls.user_span)
|
||||
cls.employee_manager = cls._new_employee("Cascade Manager", cls.user_manager)
|
||||
|
||||
cls.project = cls.env["project.project"].create(
|
||||
{
|
||||
"name": "Delete Cascade Project",
|
||||
"company_id": cls.company.id,
|
||||
"allow_timesheets": True,
|
||||
"is_timesheet_task_required": False,
|
||||
}
|
||||
)
|
||||
cls.leave_type = cls.env["hr.leave.type"].create(
|
||||
{
|
||||
"name": "Paid Time Off (cascade test)",
|
||||
"company_id": cls.company.id,
|
||||
"requires_allocation": "yes",
|
||||
"allocation_validation_type": "no",
|
||||
"leave_validation_type": "no_validation",
|
||||
"timesheet_generate": True,
|
||||
"timesheet_project_id": cls.internal_project.id,
|
||||
"timesheet_task_id": cls.timeoff_task.id,
|
||||
}
|
||||
)
|
||||
cls._allocate(cls.employee)
|
||||
cls._allocate(cls.employee_span)
|
||||
|
||||
cls.leave = cls._request_leave(
|
||||
cls.employee,
|
||||
datetime(2026, 10, 6, 6, 0, 0),
|
||||
datetime(2026, 10, 8, 18, 0, 0),
|
||||
)
|
||||
cls.leave_lines = cls.leave.sudo().timesheet_ids
|
||||
cls.leave_span = cls._request_leave(
|
||||
cls.employee_span,
|
||||
datetime(2026, 10, 8, 6, 0, 0),
|
||||
datetime(2026, 10, 13, 18, 0, 0),
|
||||
)
|
||||
cls.leave_span_lines = cls.leave_span.sudo().timesheet_ids
|
||||
|
||||
cls.sheet = cls._new_sheet(cls.employee)
|
||||
cls.regular_line = cls._new_regular_line(cls.employee, 4.0)
|
||||
cls.sheet._compute_timesheet_ids()
|
||||
cls.sheet_other = cls._new_sheet(cls.employee_other)
|
||||
|
||||
# Fixture helpers
|
||||
|
||||
@classmethod
|
||||
def _new_user(cls, login, group="hr_timesheet.group_hr_timesheet_user"):
|
||||
return (
|
||||
cls.env["res.users"]
|
||||
.with_context(no_reset_password=True, default_role_line_ids=[])
|
||||
.create(
|
||||
{
|
||||
"name": login,
|
||||
"login": login,
|
||||
"email": "%s@example.org" % login,
|
||||
"company_id": cls.company.id,
|
||||
"company_ids": [(6, 0, [cls.company.id])],
|
||||
"groups_id": [
|
||||
(
|
||||
6,
|
||||
0,
|
||||
[
|
||||
cls.env.ref(group).id,
|
||||
cls.env.ref("project.group_project_user").id,
|
||||
# Third party add-ons read project_id.stage_id
|
||||
# when a timesheet line is written, and that
|
||||
# field is protected by this group.
|
||||
cls.env.ref("project.group_project_stages").id,
|
||||
],
|
||||
)
|
||||
],
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _new_internal_user(cls, login):
|
||||
return (
|
||||
cls.env["res.users"]
|
||||
.with_context(no_reset_password=True, default_role_line_ids=[])
|
||||
.create(
|
||||
{
|
||||
"name": login,
|
||||
"login": login,
|
||||
"email": "%s@example.org" % login,
|
||||
"company_id": cls.company.id,
|
||||
"company_ids": [(6, 0, [cls.company.id])],
|
||||
"groups_id": [(6, 0, [cls.env.ref("base.group_user").id])],
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _new_employee(cls, name, user):
|
||||
return cls.env["hr.employee"].create(
|
||||
{"name": name, "user_id": user.id, "company_id": cls.company.id}
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _allocate(cls, employee):
|
||||
allocation = cls.env["hr.leave.allocation"].create(
|
||||
{
|
||||
"name": "Allocation for %s" % employee.name,
|
||||
"holiday_status_id": cls.leave_type.id,
|
||||
"employee_id": employee.id,
|
||||
"number_of_days": 25,
|
||||
"date_from": date(2026, 1, 1),
|
||||
}
|
||||
)
|
||||
if allocation.state != "validate":
|
||||
allocation.action_validate()
|
||||
return allocation
|
||||
|
||||
@classmethod
|
||||
def _request_leave(cls, employee, date_from, date_to):
|
||||
return cls.env["hr.leave"].create(
|
||||
{
|
||||
"name": "Time off for %s" % employee.name,
|
||||
"holiday_status_id": cls.leave_type.id,
|
||||
"employee_id": employee.id,
|
||||
"date_from": date_from,
|
||||
"date_to": date_to,
|
||||
}
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _new_sheet(cls, employee):
|
||||
return cls.env["hr_timesheet.sheet"].create(
|
||||
{
|
||||
"employee_id": employee.id,
|
||||
"company_id": cls.company.id,
|
||||
"date_start": WEEK_START,
|
||||
"date_end": WEEK_END,
|
||||
}
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _new_regular_line(cls, employee, hours):
|
||||
return cls.env["account.analytic.line"].create(
|
||||
{
|
||||
"name": "Regular work",
|
||||
"project_id": cls.project.id,
|
||||
"employee_id": employee.id,
|
||||
"date": WEEK_START,
|
||||
"unit_amount": hours,
|
||||
}
|
||||
)
|
||||
|
||||
# Test helpers
|
||||
|
||||
def _set_policy(self, policy):
|
||||
self.company.timesheet_sheet_delete_leave_policy = policy
|
||||
|
||||
def _recompute(self, records):
|
||||
token = self.env["account.analytic.line"]._sheet_recompute_token()
|
||||
return records.with_context(sheet_recompute=token)
|
||||
|
||||
def _cascade(self, records):
|
||||
token = self.env["account.analytic.line"]._sheet_delete_cascade_token()
|
||||
return records.with_context(sheet_delete_cascade=token)
|
||||
|
||||
def _wizard(self, sheets, user=None):
|
||||
return (
|
||||
self.env["hr_timesheet.sheet.delete.wizard"]
|
||||
.with_user(user or self.user)
|
||||
.with_context(
|
||||
active_model="hr_timesheet.sheet", active_ids=sheets.ids
|
||||
)
|
||||
.create({})
|
||||
)
|
||||
|
||||
def _wizard_from_form(self, sheets, user=None):
|
||||
user = user or self.user
|
||||
action = sheets.with_user(user).action_delete_sheet()
|
||||
return (
|
||||
self.env["hr_timesheet.sheet.delete.wizard"]
|
||||
.with_user(user)
|
||||
.with_context(**action["context"])
|
||||
.create({})
|
||||
)
|
||||
|
||||
def _leave_balance(self, employee):
|
||||
leave_type = self.leave_type.with_context(employee_id=employee.id)
|
||||
leave_type.invalidate_recordset()
|
||||
return leave_type.leaves_taken, leave_type.virtual_remaining_leaves
|
||||
|
||||
def _span_sheet(self):
|
||||
sheet = self._new_sheet(self.employee_span)
|
||||
sheet._compute_timesheet_ids()
|
||||
return sheet
|
||||
|
||||
def _second_company_sheet_with_leave(self, policy):
|
||||
company = self.env["res.company"].create({"name": "Cascade Co %s" % policy})
|
||||
company.resource_calendar_id.tz = "UTC"
|
||||
company.timesheet_sheet_delete_leave_policy = policy
|
||||
user = (
|
||||
self.env["res.users"]
|
||||
.with_context(no_reset_password=True, default_role_line_ids=[])
|
||||
.create(
|
||||
{
|
||||
"name": "tsdc_%s" % policy,
|
||||
"login": "tsdc_%s" % policy,
|
||||
"email": "tsdc_%s@example.org" % policy,
|
||||
"company_id": company.id,
|
||||
"company_ids": [(6, 0, [company.id])],
|
||||
"groups_id": [
|
||||
(
|
||||
6,
|
||||
0,
|
||||
[
|
||||
self.env.ref(
|
||||
"hr_timesheet.group_hr_timesheet_user"
|
||||
).id,
|
||||
self.env.ref("project.group_project_user").id,
|
||||
self.env.ref("project.group_project_stages").id,
|
||||
],
|
||||
)
|
||||
],
|
||||
}
|
||||
)
|
||||
)
|
||||
employee = self.env["hr.employee"].create(
|
||||
{
|
||||
"name": "Employee %s" % policy,
|
||||
"user_id": user.id,
|
||||
"company_id": company.id,
|
||||
}
|
||||
)
|
||||
leave_type = self.env["hr.leave.type"].create(
|
||||
{
|
||||
"name": "Paid Time Off (%s)" % policy,
|
||||
"company_id": company.id,
|
||||
"requires_allocation": "yes",
|
||||
"allocation_validation_type": "no",
|
||||
"leave_validation_type": "no_validation",
|
||||
"timesheet_generate": True,
|
||||
"timesheet_project_id": company.internal_project_id.id,
|
||||
"timesheet_task_id": company.leave_timesheet_task_id.id,
|
||||
}
|
||||
)
|
||||
allocation = self.env["hr.leave.allocation"].create(
|
||||
{
|
||||
"name": "Allocation %s" % policy,
|
||||
"holiday_status_id": leave_type.id,
|
||||
"employee_id": employee.id,
|
||||
"number_of_days": 25,
|
||||
"date_from": date(2026, 1, 1),
|
||||
}
|
||||
)
|
||||
if allocation.state != "validate":
|
||||
allocation.action_validate()
|
||||
leave = self.env["hr.leave"].create(
|
||||
{
|
||||
"name": "Time off %s" % policy,
|
||||
"holiday_status_id": leave_type.id,
|
||||
"employee_id": employee.id,
|
||||
"date_from": datetime(2026, 10, 6, 6, 0, 0),
|
||||
"date_to": datetime(2026, 10, 7, 18, 0, 0),
|
||||
}
|
||||
)
|
||||
sheet = self.env["hr_timesheet.sheet"].create(
|
||||
{
|
||||
"employee_id": employee.id,
|
||||
"company_id": company.id,
|
||||
"date_start": WEEK_START,
|
||||
"date_end": WEEK_END,
|
||||
}
|
||||
)
|
||||
sheet._compute_timesheet_ids()
|
||||
return {
|
||||
"company": company,
|
||||
"user": user,
|
||||
"employee": employee,
|
||||
"leave": leave,
|
||||
"sheet": sheet,
|
||||
}
|
||||
|
||||
def _invoice(self):
|
||||
journal = self.env["account.journal"].create(
|
||||
{
|
||||
"name": "Cascade Test Journal",
|
||||
"code": "CTJ",
|
||||
"type": "general",
|
||||
"company_id": self.company.id,
|
||||
}
|
||||
)
|
||||
return self.env["account.move"].create(
|
||||
{
|
||||
"move_type": "entry",
|
||||
"journal_id": journal.id,
|
||||
"company_id": self.company.id,
|
||||
}
|
||||
)
|
||||
|
||||
# Fixture sanity
|
||||
|
||||
def test_fixture(self):
|
||||
self.assertEqual(len(self.leave_lines), 3)
|
||||
self.assertEqual(self.leave.state, "validate")
|
||||
self.assertEqual(len(self.leave_span_lines), 4)
|
||||
self.assertEqual(
|
||||
self.sheet.timesheet_ids, self.leave_lines | self.regular_line
|
||||
)
|
||||
|
||||
# RF-01
|
||||
|
||||
def test_wizard_preview_does_not_delete(self):
|
||||
wizard = self._wizard(self.sheet)
|
||||
self.assertEqual(wizard.sheet_count, 1)
|
||||
self.assertEqual(wizard.timesheet_count, len(self.sheet.timesheet_ids))
|
||||
self.assertEqual(
|
||||
wizard.timesheet_hours,
|
||||
sum(self.sheet.timesheet_ids.mapped("unit_amount")),
|
||||
)
|
||||
self.assertIn(self.sheet.complete_name, wizard.summary_html)
|
||||
self.assertTrue(self.sheet.exists())
|
||||
self.assertEqual(self.sheet.timesheet_ids.exists(), self.sheet.timesheet_ids)
|
||||
|
||||
def test_wizard_action_opens_on_selected_sheets(self):
|
||||
action = self.sheet.with_user(self.user).action_delete_sheet()
|
||||
self.assertEqual(action["res_model"], "hr_timesheet.sheet.delete.wizard")
|
||||
self.assertEqual(action["context"]["active_ids"], self.sheet.ids)
|
||||
|
||||
# Navigation once the sheet the user was looking at is gone
|
||||
|
||||
def test_delete_from_form_returns_to_the_sheet_list(self):
|
||||
wizard = self._wizard_from_form(self.sheet)
|
||||
action = wizard.action_confirm_delete()
|
||||
self.assertEqual(action["type"], "ir.actions.act_window")
|
||||
self.assertEqual(action["res_model"], "hr_timesheet.sheet")
|
||||
self.assertEqual(action["target"], "main")
|
||||
self.assertIn("tree", action["view_mode"])
|
||||
self.assertFalse(self.sheet.exists())
|
||||
|
||||
def test_delete_from_form_returns_own_sheets_to_an_employee(self):
|
||||
action = self._wizard_from_form(self.sheet).action_confirm_delete()
|
||||
self.assertEqual(
|
||||
action["xml_id"],
|
||||
"hr_timesheet_sheet.act_hr_timesheet_sheet_my_timesheets",
|
||||
)
|
||||
|
||||
def test_delete_from_form_returns_own_sheets_to_a_manager(self):
|
||||
manager_sheet = self._new_sheet(self.employee_manager)
|
||||
action = self._wizard_from_form(
|
||||
manager_sheet, user=self.user_manager
|
||||
).action_confirm_delete()
|
||||
self.assertEqual(
|
||||
action["xml_id"],
|
||||
"hr_timesheet_sheet.act_hr_timesheet_sheet_my_timesheets",
|
||||
)
|
||||
|
||||
def test_delete_of_another_employee_sheet_returns_all_sheets(self):
|
||||
action = self._wizard_from_form(
|
||||
self.sheet_other, user=self.user_manager
|
||||
).action_confirm_delete()
|
||||
self.assertEqual(
|
||||
action["xml_id"],
|
||||
"hr_timesheet_sheet.act_hr_timesheet_sheet_all_timesheets",
|
||||
)
|
||||
self.assertFalse(self.sheet_other.exists())
|
||||
|
||||
def test_delete_from_list_stays_on_the_list(self):
|
||||
action = self._wizard(self.sheet).action_confirm_delete()
|
||||
self.assertEqual(action, {"type": "ir.actions.act_window_close"})
|
||||
self.assertFalse(self.sheet.exists())
|
||||
|
||||
def test_wizard_is_not_bound_to_the_sheet_form(self):
|
||||
action = self.env.ref(
|
||||
"hr_timesheet_sheet_delete_cascade"
|
||||
".action_hr_timesheet_sheet_delete_wizard"
|
||||
)
|
||||
self.assertNotIn("form", action.binding_view_types)
|
||||
|
||||
def test_blocked_deletion_navigates_nowhere(self):
|
||||
self.regular_line.timesheet_invoice_id = self._invoice()
|
||||
wizard = self._wizard_from_form(self.sheet)
|
||||
with self.assertRaises(UserError):
|
||||
wizard.action_confirm_delete()
|
||||
self.assertTrue(self.sheet.exists())
|
||||
|
||||
# RF-02
|
||||
|
||||
def test_cascade_deletes_timesheets(self):
|
||||
self._set_policy("detach")
|
||||
sheet = self.sheet_other
|
||||
line_a = self._new_regular_line(self.employee_other, 2.0)
|
||||
line_b = self._new_regular_line(self.employee_other, 3.0)
|
||||
sheet._compute_timesheet_ids()
|
||||
line_ids = sheet.timesheet_ids.ids
|
||||
self.assertEqual(sorted(line_ids), sorted((line_a | line_b).ids))
|
||||
self._wizard(sheet, user=self.user_other).action_confirm_delete()
|
||||
self.assertFalse(sheet.exists())
|
||||
self.assertFalse(self.env["account.analytic.line"].browse(line_ids).exists())
|
||||
|
||||
def test_bare_unlink_is_refused(self):
|
||||
with self.assertRaises(UserError):
|
||||
self.sheet.with_user(self.user).unlink()
|
||||
self.assertTrue(self.sheet.exists())
|
||||
|
||||
# RF-03
|
||||
|
||||
def test_user_cannot_delete_other_sheet(self):
|
||||
other = self.sheet_other.with_user(self.user)
|
||||
with self.assertRaises(AccessError):
|
||||
other.check_access_rule("unlink")
|
||||
with self.assertRaises(AccessError):
|
||||
other.check_access_rule("write")
|
||||
|
||||
def test_user_can_delete_own_sheet(self):
|
||||
own = self.sheet.with_user(self.user)
|
||||
own.check_access_rule("unlink")
|
||||
own.check_access_rule("write")
|
||||
|
||||
def test_internal_user_without_timesheet_group_cannot_delete_other_sheet(self):
|
||||
self.assertFalse(
|
||||
self.user_internal.has_group("hr_timesheet.group_hr_timesheet_user")
|
||||
)
|
||||
sheet = self.sheet.with_user(self.user_internal)
|
||||
with self.assertRaises(AccessError):
|
||||
sheet.check_access_rule("unlink")
|
||||
with self.assertRaises(AccessError):
|
||||
sheet.check_access_rule("write")
|
||||
|
||||
def test_manager_can_delete_any_sheet(self):
|
||||
sheets = (self.sheet | self.sheet_other).with_user(self.user_manager)
|
||||
sheets.check_access_rule("unlink")
|
||||
sheets.check_access_rule("write")
|
||||
|
||||
# RF-04
|
||||
|
||||
def test_invoiced_timesheets_block_deletion(self):
|
||||
self.regular_line.timesheet_invoice_id = self._invoice()
|
||||
wizard = self._wizard(self.sheet)
|
||||
self.assertEqual(wizard.invoiced_count, 1)
|
||||
self.assertTrue(wizard.blocking_reason)
|
||||
self.assertIn(self.sheet.complete_name, wizard.blocking_reason)
|
||||
self.assertIn("1", wizard.blocking_reason)
|
||||
with self.assertRaises(UserError):
|
||||
wizard.action_confirm_delete()
|
||||
with self.assertRaises(UserError):
|
||||
self._cascade(self.sheet.with_user(self.user)).unlink()
|
||||
self.assertTrue(self.sheet.exists())
|
||||
|
||||
# RF-05
|
||||
|
||||
def test_policy_default_is_detach(self):
|
||||
company = self.env["res.company"].create({"name": "Fresh Cascade Co"})
|
||||
self.assertEqual(company.timesheet_sheet_delete_leave_policy, "detach")
|
||||
self.assertEqual(self.sheet._sheet_leave_policy(), "detach")
|
||||
|
||||
def test_policy_detach_keeps_leave(self):
|
||||
self._set_policy("detach")
|
||||
leave_line_ids = self.leave_lines.ids
|
||||
self._wizard(self.sheet).action_confirm_delete()
|
||||
self.assertFalse(self.sheet.exists())
|
||||
self.assertFalse(self.regular_line.exists())
|
||||
lines = self.env["account.analytic.line"].browse(leave_line_ids)
|
||||
self.assertEqual(lines.exists(), lines)
|
||||
self.assertFalse(lines.mapped("sheet_id"))
|
||||
self.assertTrue(self.leave.exists())
|
||||
self.assertEqual(self.leave.state, "validate")
|
||||
|
||||
def test_policy_detach_keeps_balance(self):
|
||||
self._set_policy("detach")
|
||||
taken, remaining = self._leave_balance(self.employee)
|
||||
self._wizard(self.sheet).action_confirm_delete()
|
||||
self.assertEqual(self._leave_balance(self.employee), (taken, remaining))
|
||||
|
||||
# RF-06
|
||||
|
||||
def test_cascade_cancels_leaves_and_restores_balance(self):
|
||||
self._set_policy("cancel")
|
||||
taken, remaining = self._leave_balance(self.employee)
|
||||
self.assertEqual(taken, 3.0)
|
||||
leave_line_ids = self.leave_lines.ids
|
||||
leave = self.leave
|
||||
self._wizard(self.sheet).action_confirm_delete()
|
||||
self.assertFalse(self.sheet.exists())
|
||||
self.assertFalse(
|
||||
self.env["account.analytic.line"].browse(leave_line_ids).exists()
|
||||
)
|
||||
self.assertFalse(self.regular_line.exists())
|
||||
self.assertFalse(leave.exists())
|
||||
new_taken, new_remaining = self._leave_balance(self.employee)
|
||||
self.assertEqual(new_taken, taken - 3.0)
|
||||
self.assertEqual(new_remaining, remaining + 3.0)
|
||||
|
||||
# RF-07
|
||||
|
||||
def test_wizard_lists_leaves(self):
|
||||
self._set_policy("cancel")
|
||||
wizard = self._wizard(self.sheet)
|
||||
self.assertEqual(wizard.leave_policy, "cancel")
|
||||
self.assertEqual(wizard.leave_count, len(self.leave_lines))
|
||||
self.assertEqual(wizard.leave_days, 3.0)
|
||||
self.assertEqual(wizard.leave_ids, self.leave)
|
||||
self.assertFalse(wizard.partial_leave_ids)
|
||||
self.assertIn(self.employee.name, wizard.summary_html)
|
||||
self.assertIn(self.leave_type.name, wizard.summary_html)
|
||||
|
||||
def test_wizard_warns_about_leaves_spanning_sheets(self):
|
||||
self._set_policy("cancel")
|
||||
sheet = self._span_sheet()
|
||||
wizard = self._wizard(sheet, user=self.user_span)
|
||||
self.assertEqual(wizard.partial_leave_ids, self.leave_span)
|
||||
self.assertIn("Warning", wizard.summary_html)
|
||||
self.assertIn(self.employee_span.name, wizard.summary_html)
|
||||
|
||||
def test_wizard_detach_policy_states_leaves_untouched(self):
|
||||
self._set_policy("detach")
|
||||
wizard = self._wizard(self.sheet)
|
||||
self.assertEqual(wizard.leave_policy, "detach")
|
||||
self.assertEqual(wizard.leave_count, len(self.leave_lines))
|
||||
self.assertEqual(wizard.leave_days, 0.0)
|
||||
self.assertFalse(wizard.leave_ids)
|
||||
self.assertFalse(wizard.partial_leave_ids)
|
||||
self.assertIn("balances do not change", wizard.summary_html)
|
||||
|
||||
def test_wizard_without_leaves_has_no_leave_section(self):
|
||||
line = self._new_regular_line(self.employee_other, 2.0)
|
||||
sheet = self.sheet_other
|
||||
sheet._compute_timesheet_ids()
|
||||
self.assertEqual(sheet.timesheet_ids, line)
|
||||
for policy in ("detach", "cancel"):
|
||||
self._set_policy(policy)
|
||||
wizard = self._wizard(sheet, user=self.user_other)
|
||||
self.assertEqual(wizard.leave_count, 0)
|
||||
self.assertNotIn("Time off", wizard.summary_html)
|
||||
|
||||
def test_uncancellable_leave_blocks_deletion(self):
|
||||
self._set_policy("cancel")
|
||||
# A request already refused outside this module keeps its entries:
|
||||
# action_refuse() would have removed them, a data fix would not.
|
||||
self.leave.with_user(SUPERUSER_ID).sudo().write({"state": "refuse"})
|
||||
self.assertEqual(self.sheet._sheet_leaves_to_cancel(), self.env["hr.leave"])
|
||||
self.assertEqual(self.sheet._sheet_leaves_not_cancellable(), self.leave)
|
||||
wizard = self._wizard(self.sheet)
|
||||
self.assertTrue(wizard.blocking_reason)
|
||||
self.assertIn(self.sheet.complete_name, wizard.blocking_reason)
|
||||
self.assertIn("cannot be cancelled", wizard.blocking_reason)
|
||||
with self.assertRaises(UserError):
|
||||
wizard.action_confirm_delete()
|
||||
self.env.invalidate_all()
|
||||
self.assertTrue(self.sheet.exists())
|
||||
self.assertEqual(self.sheet.timesheet_ids.exists(), self.sheet.timesheet_ids)
|
||||
|
||||
def test_multicompany_batch_summary_matches_each_policy(self):
|
||||
self._set_policy("cancel")
|
||||
other = self._second_company_sheet_with_leave("detach")
|
||||
self.env.user.write({"company_ids": [(4, other["company"].id)]})
|
||||
sheets = self.sheet | other["sheet"]
|
||||
data = sheets._delete_cascade_prepare()
|
||||
titles = [section["title"] for section in data["sections"]]
|
||||
# Compared against the titles the model itself produces, so the test
|
||||
# does not depend on the language of the user running it.
|
||||
self.assertIn(other["sheet"]._delete_cascade_detach_section()["title"], titles)
|
||||
self.assertIn(self.sheet._delete_cascade_cancel_section()[0]["title"], titles)
|
||||
self.assertEqual(data["leave_days"], 3.0)
|
||||
self.assertEqual(data["leave_count"], 5)
|
||||
cancel_title = self.sheet._delete_cascade_cancel_section()[0]["title"]
|
||||
cancelled = " ".join(
|
||||
line
|
||||
for section in data["sections"]
|
||||
if section["title"] == cancel_title
|
||||
for line in section["lines"]
|
||||
)
|
||||
self.assertIn(self.employee.name, cancelled)
|
||||
self.assertNotIn(other["employee"].name, cancelled)
|
||||
wizard = self._wizard(sheets, user=self.env.user)
|
||||
self.assertEqual(wizard.leave_ids, self.leave)
|
||||
self.assertEqual(wizard.leave_days, 3.0)
|
||||
|
||||
def test_public_holiday_lines_created_while_cancelling_are_deleted(self):
|
||||
self._set_policy("cancel")
|
||||
global_leave = (
|
||||
self.env["resource.calendar.leaves"]
|
||||
.with_context(allowed_company_ids=[self.company.id])
|
||||
.create(
|
||||
{
|
||||
"name": "Public holiday",
|
||||
"calendar_id": self.company.resource_calendar_id.id,
|
||||
"company_id": self.company.id,
|
||||
"date_from": datetime(2026, 10, 7, 0, 0, 0),
|
||||
"date_to": datetime(2026, 10, 7, 23, 59, 0),
|
||||
}
|
||||
)
|
||||
)
|
||||
self.assertFalse(global_leave.timesheet_ids)
|
||||
sheet_id = self.sheet.id
|
||||
self._wizard(self.sheet).action_confirm_delete()
|
||||
self.env.flush_all()
|
||||
self.env.invalidate_all()
|
||||
self.assertFalse(self.sheet.exists())
|
||||
self.assertFalse(global_leave.timesheet_ids)
|
||||
self.env.cr.execute(
|
||||
"SELECT COUNT(*) FROM account_analytic_line WHERE sheet_id = %s",
|
||||
(sheet_id,),
|
||||
)
|
||||
self.assertEqual(self.env.cr.fetchone()[0], 0)
|
||||
|
||||
# RF-08
|
||||
|
||||
def test_bare_unlink_of_leave_line_is_refused(self):
|
||||
line = self.leave_lines[0].with_user(self.user)
|
||||
with self.assertRaises(UserError):
|
||||
line.unlink()
|
||||
self.env.invalidate_all()
|
||||
self.assertTrue(self.leave_lines[0].exists())
|
||||
self.assertTrue(self.leave_lines[0].sheet_id)
|
||||
|
||||
def test_unlink_during_sheet_recompute_detaches(self):
|
||||
line = self.leave_lines[0].with_user(self.user)
|
||||
self._recompute(line).unlink()
|
||||
self.env.invalidate_all()
|
||||
self.assertTrue(self.leave_lines[0].exists())
|
||||
self.assertFalse(self.leave_lines[0].sheet_id)
|
||||
self.assertTrue(self.leave.exists())
|
||||
|
||||
def test_unlink_during_delete_cascade_cancel_deletes(self):
|
||||
self._set_policy("cancel")
|
||||
line = self.leave_lines[0]
|
||||
self._cascade(line.with_user(self.user).sudo()).unlink()
|
||||
self.assertFalse(line.exists())
|
||||
|
||||
def test_unlink_during_delete_cascade_detach_detaches(self):
|
||||
self._set_policy("detach")
|
||||
line = self.leave_lines[0]
|
||||
self._cascade(line.with_user(self.user).sudo()).unlink()
|
||||
self.env.invalidate_all()
|
||||
self.assertTrue(line.exists())
|
||||
self.assertFalse(line.sheet_id)
|
||||
self.assertTrue(self.leave.exists())
|
||||
|
||||
def test_delete_cascade_flag_without_sudo_is_refused(self):
|
||||
line = self.leave_lines[0].with_user(self.user)
|
||||
for policy in ("detach", "cancel"):
|
||||
self._set_policy(policy)
|
||||
with self.assertRaises(UserError):
|
||||
self._cascade(line).unlink()
|
||||
self.env.invalidate_all()
|
||||
self.assertTrue(self.leave_lines[0].exists())
|
||||
self.assertTrue(self.leave_lines[0].sheet_id)
|
||||
|
||||
def test_both_flags_delete_cascade_wins(self):
|
||||
self._set_policy("cancel")
|
||||
line = self.leave_lines[0]
|
||||
self._cascade(self._recompute(line.with_user(self.user).sudo())).unlink()
|
||||
self.assertFalse(line.exists())
|
||||
|
||||
def test_both_flags_with_detach_policy_still_detaches(self):
|
||||
self._set_policy("detach")
|
||||
line = self.leave_lines[0]
|
||||
self._cascade(self._recompute(line.with_user(self.user).sudo())).unlink()
|
||||
self.env.invalidate_all()
|
||||
self.assertTrue(line.exists())
|
||||
self.assertFalse(line.sheet_id)
|
||||
|
||||
# RF-09
|
||||
|
||||
def test_no_cascade_flag_leak_after_deletion(self):
|
||||
self._set_policy("cancel")
|
||||
self._wizard(self.sheet).action_confirm_delete()
|
||||
self.assertIsNone(self.env.context.get("sheet_delete_cascade"))
|
||||
other_line = self.leave_span_lines[0].with_user(self.user_span)
|
||||
with self.assertRaises(UserError):
|
||||
other_line.unlink()
|
||||
self.env.invalidate_all()
|
||||
self.assertTrue(self.leave_span_lines[0].exists())
|
||||
self.assertIsNone(self.env.context.get("sheet_delete_cascade"))
|
||||
|
||||
# Published hooks
|
||||
|
||||
def test_delete_cascade_prepare_contract(self):
|
||||
data = self.sheet._delete_cascade_prepare()
|
||||
self.assertEqual(
|
||||
set(data),
|
||||
{
|
||||
"sheet_count",
|
||||
"timesheet_count",
|
||||
"timesheet_hours",
|
||||
"invoiced_count",
|
||||
"leave_count",
|
||||
"leave_days",
|
||||
"leave_policy",
|
||||
"blocking",
|
||||
"sections",
|
||||
},
|
||||
)
|
||||
self.assertEqual(data["blocking"], [])
|
||||
self.assertTrue(all("title" in s and "lines" in s for s in data["sections"]))
|
||||
|
||||
def test_detach_hook_matrix(self):
|
||||
"""One assertion per row of the RF-08 table, on the same leave line.
|
||||
|
||||
The base recordset is bound to a plain employee on purpose: the class
|
||||
env is superuser, so a line taken straight from the fixture would carry
|
||||
env.su and the row that must fail without it would pass for the wrong
|
||||
reason.
|
||||
"""
|
||||
line = self.leave_lines[0].with_user(self.user)
|
||||
self.assertFalse(line.env.su)
|
||||
line_su = line.sudo()
|
||||
|
||||
# Row 1: no flags.
|
||||
self.assertFalse(line._detach_leave_lines_on_unlink())
|
||||
# Row 2: sheet recompute, any policy.
|
||||
self.assertTrue(self._recompute(line)._detach_leave_lines_on_unlink())
|
||||
|
||||
self._set_policy("cancel")
|
||||
# Row 3: deliberate cascade with cancel deletes instead of detaching.
|
||||
self.assertFalse(self._cascade(line_su)._detach_leave_lines_on_unlink())
|
||||
# Row 5, cancel: the sentinel alone, without env.su, detaches nothing.
|
||||
self.assertFalse(self._cascade(line)._detach_leave_lines_on_unlink())
|
||||
self.assertFalse(self._cascade(line)._in_deliberate_delete_cascade())
|
||||
# Row 6: the cascade wins over a concurrent recompute.
|
||||
self.assertFalse(
|
||||
self._cascade(self._recompute(line_su))._detach_leave_lines_on_unlink()
|
||||
)
|
||||
|
||||
self._set_policy("detach")
|
||||
# Row 4: deliberate cascade with detach keeps the line alive.
|
||||
self.assertTrue(self._cascade(line_su)._detach_leave_lines_on_unlink())
|
||||
# Row 5, detach: still nothing without env.su.
|
||||
self.assertFalse(self._cascade(line)._detach_leave_lines_on_unlink())
|
||||
self.assertFalse(self._cascade(line)._in_deliberate_delete_cascade())
|
||||
|
||||
def test_context_token_is_not_serializable(self):
|
||||
token = self.env["account.analytic.line"]._sheet_delete_cascade_token()
|
||||
with self.assertRaises(TypeError):
|
||||
json.dumps(token)
|
||||
|
||||
def test_forged_cascade_flag_does_not_delete_sheet(self):
|
||||
model = self.env["hr_timesheet.sheet"].with_user(self.user)
|
||||
with self.assertRaises(UserError):
|
||||
call_kw(
|
||||
model,
|
||||
"unlink",
|
||||
[self.sheet.ids],
|
||||
{"context": {"sheet_delete_cascade": True}},
|
||||
)
|
||||
self.env.invalidate_all()
|
||||
self.assertTrue(self.sheet.exists())
|
||||
self.assertEqual(self.sheet.timesheet_ids.exists(), self.sheet.timesheet_ids)
|
||||
self.assertFalse(
|
||||
model.with_context(sheet_delete_cascade=True)._in_delete_cascade()
|
||||
)
|
||||
|
||||
def test_forged_cascade_flag_does_not_delete_leave_line(self):
|
||||
model = self.env["account.analytic.line"].with_user(self.user)
|
||||
line = self.leave_lines[0]
|
||||
self._set_policy("cancel")
|
||||
with self.assertRaises(UserError):
|
||||
call_kw(
|
||||
model,
|
||||
"unlink",
|
||||
[[line.id]],
|
||||
{"context": {"sheet_delete_cascade": True}},
|
||||
)
|
||||
self.env.invalidate_all()
|
||||
self.assertTrue(line.exists())
|
||||
self.assertFalse(
|
||||
model.sudo()
|
||||
.with_context(sheet_delete_cascade=True)
|
||||
._in_deliberate_delete_cascade()
|
||||
)
|
||||
|
|
@ -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
|
||||
152
hr_timesheet_sheet_delete_cascade/wizards/delete_sheet_wizard.py
Normal file
152
hr_timesheet_sheet_delete_cascade/wizards/delete_sheet_wizard.py
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
# 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 _delete_cascade_list_action_xml_id(self):
|
||||
"""Hook for extensions
|
||||
|
||||
The list the user plausibly comes from. Deleting the sheet of another
|
||||
employee requires *Timesheets / Administrator*, and that sheet is only
|
||||
listed by *All Timesheet Sheets*; every other case is a user looking at
|
||||
his own sheets, and *My Timesheet Sheets* is the only list of sheets an
|
||||
employee outside that group has a menu for.
|
||||
"""
|
||||
self.ensure_one()
|
||||
own_sheets = self.sheet_ids.user_id == self.env.user
|
||||
is_manager = self.env.user.has_group("hr_timesheet.group_timesheet_manager")
|
||||
if own_sheets or not is_manager:
|
||||
return "hr_timesheet_sheet.act_hr_timesheet_sheet_my_timesheets"
|
||||
return "hr_timesheet_sheet.act_hr_timesheet_sheet_all_timesheets"
|
||||
|
||||
def _delete_cascade_next_action(self):
|
||||
"""Hook for extensions
|
||||
|
||||
Called before the deletion, while the sheets can still be read.
|
||||
|
||||
Opened from a list, the caller is left on that list: closing the dialog
|
||||
reloads it and keeps its filters, its grouping and its selection, and
|
||||
there is no dead record on screen. Opened from the form of a sheet that
|
||||
no longer exists, closing the dialog would leave that form on screen
|
||||
with the values of a deleted record: return the list of sheets instead,
|
||||
with ``target="main"`` so the deleted form is dropped from the
|
||||
breadcrumb rather than kept one step behind.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not self.env.context.get("sheet_delete_cascade_from_form"):
|
||||
return {"type": "ir.actions.act_window_close"}
|
||||
action = self.env["ir.actions.act_window"]._for_xml_id(
|
||||
self._delete_cascade_list_action_xml_id()
|
||||
)
|
||||
action["target"] = "main"
|
||||
return action
|
||||
|
||||
def action_confirm_delete(self):
|
||||
self.ensure_one()
|
||||
next_action = self._delete_cascade_next_action()
|
||||
self.sheet_ids._with_delete_cascade().unlink()
|
||||
return next_action
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
<?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"
|
||||
/>
|
||||
<!--
|
||||
Not bound to the form view: there the Delete Sheet button of the
|
||||
header is the only entry point, and it is the one that tells the
|
||||
wizard the sheet form has to be left behind once the sheet is gone.
|
||||
-->
|
||||
<field name="binding_view_types">list,kanban</field>
|
||||
</record>
|
||||
</odoo>
|
||||
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,144 @@
|
|||
# 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-30 16:47+0000\n"
|
||||
"PO-Revision-Date: 2026-09-30 16:47+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__sheet_ids
|
||||
msgid "Frozen Sheets To Release"
|
||||
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
|
||||
59
hr_timesheet_sheet_no_review/models/hr_timesheet_sheet.py
Normal file
59
hr_timesheet_sheet_no_review/models/hr_timesheet_sheet.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
# Copyright 2026 Criptomart
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import api, models
|
||||
from odoo.osv import expression
|
||||
|
||||
_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, sheets=None):
|
||||
"""Send frozen sheets back to draft. Returns the number of sheets affected.
|
||||
|
||||
Without ``sheets``, every frozen sheet the current user can see is
|
||||
released, which is the historical behaviour of this hook. With
|
||||
``sheets``, only those of them that are still frozen are released: the
|
||||
wizard passes the recordset it showed the user, so the confirmation
|
||||
message counts what the warning announced and not what the database
|
||||
happens to hold by the time the button is pressed.
|
||||
|
||||
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.
|
||||
"""
|
||||
domain = self._unfreeze_domain()
|
||||
if sheets is not None:
|
||||
domain = expression.AND([domain, [("id", "in", sheets.ids)]])
|
||||
sheets = self.search(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.
|
||||
35
hr_timesheet_sheet_no_review/readme/USAGE.rst
Normal file
35
hr_timesheet_sheet_no_review/readme/USAGE.rst
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
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.
|
||||
|
||||
The wizard releases exactly the sheets it listed when it was opened, and the
|
||||
confirmation message counts them. A sheet frozen by somebody else while the
|
||||
dialog was on screen is not released: it shows up the next time the wizard is
|
||||
opened. Only the frozen sheets the user can see are listed, so on a
|
||||
multi-company database the figures follow the company switcher.
|
||||
|
||||
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")]``. The domain is applied twice: once to list the
|
||||
sheets in the dialog, and again when the button is pressed, so a sheet it keeps
|
||||
out is never released.
|
||||
|
||||
``hr_timesheet.sheet._unfreeze_sheets()`` takes an optional recordset. Called
|
||||
with no argument it releases every frozen sheet, as before; called with a
|
||||
recordset it releases only those of them that the domain still considers
|
||||
frozen.
|
||||
|
|
@ -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,502 @@
|
|||
# Copyright 2026 Criptomart
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
|
||||
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
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 Form, 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,
|
||||
)
|
||||
|
||||
# The figures reach the interface
|
||||
|
||||
def test_wizard_reports_pending_sheets_in_the_interface(self):
|
||||
"""The path the user walks: default_get() + onchange(), no create().
|
||||
|
||||
Opening an action with ``target="new"`` never creates the record: the
|
||||
client builds a virtual one and the server decides what to compute from
|
||||
the dependency graph. A compute with no ``@api.depends`` is skipped and
|
||||
the dialog reports zero on a database full of frozen sheets.
|
||||
"""
|
||||
form = Form(self.wizard_model)
|
||||
self.assertEqual(form.sheet_count, self.baseline["sheets"] + 1)
|
||||
self.assertEqual(form.timesheet_count, self.baseline["timesheets"] + 1)
|
||||
self.assertAlmostEqual(
|
||||
form.timesheet_hours, self.baseline["hours"] + 3.0, places=2
|
||||
)
|
||||
self.assertNotIn("Nothing to unfreeze", form.summary_html)
|
||||
self.assertIn(self.sheet, form.sheet_ids)
|
||||
|
||||
def test_wizard_reports_pending_sheets_through_raw_onchange(self):
|
||||
"""Same flow, called the way the web client calls it.
|
||||
|
||||
``Form`` is a faithful client, but it is still test code. This drives
|
||||
``onchange()`` directly with the field list of the real view, so the
|
||||
test also fails if the view stops exposing ``sheet_ids`` and
|
||||
``default_get()`` is therefore never asked for it.
|
||||
"""
|
||||
arch = self._arch(
|
||||
"hr_timesheet_sheet_no_review.hr_timesheet_sheet_unfreeze_wizard_form",
|
||||
"form",
|
||||
model=self.wizard_model._name,
|
||||
)
|
||||
names = [node.get("name") for node in arch.xpath("//field")]
|
||||
self.assertIn("sheet_ids", names)
|
||||
values = self.wizard_model.onchange({}, [], {name: "1" for name in names})[
|
||||
"value"
|
||||
]
|
||||
self.assertEqual(values["sheet_count"], self.baseline["sheets"] + 1)
|
||||
self.assertEqual(values["timesheet_count"], self.baseline["timesheets"] + 1)
|
||||
self.assertAlmostEqual(
|
||||
values["timesheet_hours"], self.baseline["hours"] + 3.0, places=2
|
||||
)
|
||||
self.assertNotIn("Nothing to unfreeze", values["summary_html"])
|
||||
|
||||
def test_wizard_figures_depend_on_a_real_field(self):
|
||||
"""Guard: the figures must keep a dependency to be computed at all."""
|
||||
for name in ("sheet_count", "timesheet_count", "timesheet_hours"):
|
||||
field = self.wizard_model._fields[name]
|
||||
self.assertEqual(
|
||||
self.env.registry.field_depends[field],
|
||||
("sheet_ids",),
|
||||
"%s must depend on sheet_ids or it is never computed in the UI" % name,
|
||||
)
|
||||
|
||||
def test_wizard_reports_pending_sheets_from_code(self):
|
||||
"""``create({})`` is the shell path, used by the diagnosis probe."""
|
||||
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()
|
||||
form = Form(self.wizard_model)
|
||||
self.assertEqual(form.sheet_count, 0)
|
||||
self.assertEqual(form.timesheet_count, 0)
|
||||
self.assertEqual(form.timesheet_hours, 0.0)
|
||||
self.assertEqual(len(form.sheet_ids), 0)
|
||||
self.assertIn("Nothing to unfreeze", form.summary_html)
|
||||
wizard = form.save()
|
||||
self.assertIn("Nothing to unfreeze", wizard.summary_html)
|
||||
|
||||
# Unfreezing
|
||||
|
||||
def test_unfreeze_releases_sheets(self):
|
||||
self.assertEqual(self.sheet.state, "confirm")
|
||||
form = Form(self.wizard_model)
|
||||
expected = form.sheet_count
|
||||
result = form.save().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_only_releases_the_sheets_it_announced(self):
|
||||
"""The confirmation must count what the warning announced.
|
||||
|
||||
The wizard is built, and only then another sheet is frozen. That sheet
|
||||
was not on screen, so it is not released and it is not counted.
|
||||
"""
|
||||
wizard = Form(self.wizard_model).save()
|
||||
announced = wizard.sheet_count
|
||||
|
||||
late_sheet = self.sheet_model.create(
|
||||
{
|
||||
"employee_id": self.employee.id,
|
||||
"company_id": self.company.id,
|
||||
"date_start": self.sheet.date_start + relativedelta(months=1),
|
||||
"date_end": self.sheet.date_end + relativedelta(months=1),
|
||||
}
|
||||
)
|
||||
late_sheet.action_timesheet_confirm()
|
||||
|
||||
result = wizard.action_unfreeze()
|
||||
self.assertIn(str(announced), result["params"]["message"])
|
||||
self.assertEqual(self.sheet.state, "draft")
|
||||
self.assertEqual(late_sheet.state, "confirm")
|
||||
|
||||
def test_unfreeze_ignores_sheets_outside_the_domain(self):
|
||||
"""Operating on a given recordset must not relax the domain.
|
||||
|
||||
``sheet_ids`` comes from the client and can be tampered with. The
|
||||
sheets that ``_unfreeze_domain()`` keeps out stay out, whatever the
|
||||
wizard is told to release.
|
||||
"""
|
||||
draft_sheet = self.sheet_model.create(
|
||||
{
|
||||
"employee_id": self.employee.id,
|
||||
"company_id": self.company.id,
|
||||
"date_start": self.sheet.date_start + relativedelta(months=2),
|
||||
"date_end": self.sheet.date_end + relativedelta(months=2),
|
||||
}
|
||||
)
|
||||
self.assertEqual(draft_sheet.state, "draft")
|
||||
kept_id = self.sheet.id
|
||||
with patch.object(
|
||||
type(self.sheet_model),
|
||||
"_unfreeze_domain",
|
||||
lambda records: [
|
||||
("state", "in", ("confirm", "done")),
|
||||
("id", "!=", kept_id),
|
||||
],
|
||||
):
|
||||
self.assertNotIn(self.sheet, Form(self.wizard_model).sheet_ids)
|
||||
wizard = self.wizard_model.create(
|
||||
{"sheet_ids": [(6, 0, (self.sheet + draft_sheet).ids)]}
|
||||
)
|
||||
result = wizard.action_unfreeze()
|
||||
self.assertEqual(self.sheet.state, "confirm")
|
||||
self.assertEqual(draft_sheet.state, "draft")
|
||||
self.assertEqual(result["params"]["type"], "info")
|
||||
|
||||
def test_unfreeze_is_idempotent(self):
|
||||
self.assertTrue(self.sheet_model._unfreeze_sheets())
|
||||
self.assertEqual(self.sheet_model._unfreeze_sheets(), 0)
|
||||
result = Form(self.wizard_model).save().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_unfreeze_twice_from_the_interface_is_idempotent(self):
|
||||
first = Form(self.wizard_model).save()
|
||||
second = Form(self.wizard_model).save()
|
||||
self.assertTrue(first.action_unfreeze()["params"]["type"] == "success")
|
||||
result = second.action_unfreeze()
|
||||
self.assertEqual(result["params"]["type"], "info")
|
||||
self.assertEqual(self.sheet.state, "draft")
|
||||
self.assertFalse(self.sheet.reviewer_id)
|
||||
|
||||
def test_unfrozen_lines_are_editable(self):
|
||||
with self.assertRaises(UserError):
|
||||
self.line.with_user(self.user).write({"unit_amount": 5.0})
|
||||
Form(self.wizard_model).save().action_unfreeze()
|
||||
self.line.with_user(self.user).write({"unit_amount": 5.0})
|
||||
self.assertEqual(self.line.unit_amount, 5.0)
|
||||
|
||||
def test_unfreeze_sheets_hook_keeps_its_signature(self):
|
||||
"""``_unfreeze_sheets()`` is public: calling it with no argument must
|
||||
keep releasing every frozen sheet."""
|
||||
self.assertEqual(
|
||||
self.sheet_model._unfreeze_sheets(), self.baseline["sheets"] + 1
|
||||
)
|
||||
self.assertEqual(self.sheet.state, "draft")
|
||||
|
||||
def test_unfreeze_sheets_accepts_a_recordset(self):
|
||||
self.assertEqual(self.sheet_model._unfreeze_sheets(self.sheet), 1)
|
||||
self.assertEqual(self.sheet.state, "draft")
|
||||
self.assertEqual(self.sheet_model._unfreeze_sheets(self.sheet), 0)
|
||||
no_sheet = self.sheet_model.browse()
|
||||
self.assertEqual(self.sheet_model._unfreeze_sheets(no_sheet), 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({})
|
||||
manager_form = Form(self.wizard_model.with_user(self.manager))
|
||||
self.assertTrue(manager_form.sheet_count)
|
||||
self.assertTrue(manager_form.save())
|
||||
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
|
||||
98
hr_timesheet_sheet_no_review/wizards/unfreeze_wizard.py
Normal file
98
hr_timesheet_sheet_no_review/wizards/unfreeze_wizard.py
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
# 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_ids = fields.Many2many(
|
||||
comodel_name="hr_timesheet.sheet",
|
||||
string="Frozen Sheets To Release",
|
||||
)
|
||||
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",
|
||||
)
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields_list):
|
||||
res = super().default_get(fields_list)
|
||||
if "sheet_ids" in fields_list:
|
||||
sheet_model = self.env["hr_timesheet.sheet"]
|
||||
sheets = sheet_model.search(sheet_model._unfreeze_domain())
|
||||
res["sheet_ids"] = [fields.Command.set(sheets.ids)]
|
||||
return res
|
||||
|
||||
@api.depends("sheet_ids")
|
||||
def _compute_frozen_data(self):
|
||||
"""The figures describe ``sheet_ids``, not the database.
|
||||
|
||||
Depending on a real field is what makes the figures appear in the
|
||||
interface: opening an action on a transient model builds the record
|
||||
with ``default_get()`` + ``onchange()``, and that flow seeds every
|
||||
field of the view into the cache before computing anything. A compute
|
||||
with no dependency is then a cache hit on the default value, and the
|
||||
dialog reports zero.
|
||||
"""
|
||||
line_model = self.env["account.analytic.line"]
|
||||
for wizard in self:
|
||||
sheets = wizard.sheet_ids
|
||||
groups = line_model.read_group(
|
||||
[("sheet_id", "in", sheets.ids)], ["unit_amount"], []
|
||||
)
|
||||
group = groups[0] if groups else {}
|
||||
wizard.sheet_count = len(sheets)
|
||||
wizard.timesheet_count = group.get("__count") or 0
|
||||
wizard.timesheet_hours = group.get("unit_amount") or 0.0
|
||||
|
||||
@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(self.sheet_ids)
|
||||
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,56 @@
|
|||
<?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">
|
||||
<!--
|
||||
The figures depend on sheet_ids, and the onchange flow that
|
||||
builds the dialog only asks default_get() for the fields of
|
||||
the view. Drop this field and the dialog reports zero.
|
||||
-->
|
||||
<field name="sheet_ids" invisible="1" />
|
||||
<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>
|
||||
19
mail_quoted_reply_icon/__manifest__.py
Normal file
19
mail_quoted_reply_icon/__manifest__.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"name": "Mail Quoted Reply Icon",
|
||||
"summary": "Changes the icon for quoted reply from fa-reply to fa-envelope",
|
||||
"version": "16.0.1.0.0",
|
||||
"category": "Discuss",
|
||||
"license": "AGPL-3",
|
||||
"author": "Criptoamrt",
|
||||
"website": "https://criptomart.net",
|
||||
"depends": ["mail_quoted_reply"],
|
||||
"data": [],
|
||||
"demo": [],
|
||||
"installable": True,
|
||||
"application": False,
|
||||
"assets": {
|
||||
"web.assets_backend": [
|
||||
"/mail_quoted_reply_icon/static/src/models/*.js",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import {registerPatch} from "@mail/model/model_core";
|
||||
|
||||
registerPatch({
|
||||
name: "MessageActionView",
|
||||
fields: {
|
||||
classNames: {
|
||||
compute() {
|
||||
let classNames = this._super() || "";
|
||||
if (
|
||||
this.messageAction.messageActionListOwner ===
|
||||
this.messageAction.replyMessageAction
|
||||
) {
|
||||
// Replace the fa-reply icon added by mail_quoted_reply with fa-envelope
|
||||
classNames = classNames.replace("fa-reply", "fa-envelope");
|
||||
}
|
||||
return classNames;
|
||||
},
|
||||
},
|
||||
title: {
|
||||
compute() {
|
||||
if (
|
||||
this.messageAction.messageActionListOwner ===
|
||||
this.messageAction.replyMessageAction
|
||||
) {
|
||||
return this.env._t("Reply by Email");
|
||||
}
|
||||
return this._super();
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
24
partner_email_sync_portal_login/README.md
Normal file
24
partner_email_sync_portal_login/README.md
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Partner Email Sync Portal Login
|
||||
|
||||
When the email of a contact that has a **portal user** is changed and saved:
|
||||
|
||||
- the user login is updated with the new (normalized) email, and
|
||||
- a password reset email is sent to the new address.
|
||||
|
||||
A note is posted in the contact chatter with the result.
|
||||
|
||||
## Behaviour details
|
||||
|
||||
- Only portal users are affected; internal users are never modified.
|
||||
- Empty or invalid emails are ignored (login is kept).
|
||||
- Changes that only differ in letter case do not trigger anything.
|
||||
- If another user already uses the new email as login, the change is
|
||||
blocked with an error message.
|
||||
- If the reset email cannot be sent, the login is still updated and the
|
||||
error is recorded in the chatter.
|
||||
- From the portal access wizard ("Grant portal access" / "Invite again")
|
||||
the login is synced but no reset email is sent, since the wizard sends
|
||||
its own invitation.
|
||||
- Use the context key `skip_portal_login_sync` to disable the behaviour
|
||||
(e.g. in imports).
|
||||
- Changing the login closes the user's open sessions.
|
||||
2
partner_email_sync_portal_login/__init__.py
Normal file
2
partner_email_sync_portal_login/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from . import models
|
||||
from . import wizard
|
||||
17
partner_email_sync_portal_login/__manifest__.py
Normal file
17
partner_email_sync_portal_login/__manifest__.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# Copyright 2026 Criptomart
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
|
||||
|
||||
{
|
||||
"name": "Partner Email Sync Portal Login",
|
||||
"summary": "Update the portal user login and send a password reset "
|
||||
"email when the partner email changes",
|
||||
"version": "16.0.1.0.0",
|
||||
"category": "Tools",
|
||||
"website": "https://criptomart.net",
|
||||
"author": "Criptomart",
|
||||
"maintainers": ["criptomart"],
|
||||
"license": "AGPL-3",
|
||||
"depends": ["portal"],
|
||||
"data": [],
|
||||
"installable": True,
|
||||
}
|
||||
67
partner_email_sync_portal_login/i18n/es.po
Normal file
67
partner_email_sync_portal_login/i18n/es.po
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * partner_email_sync_portal_login
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server 16.0\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-10-06 09:57+0000\n"
|
||||
"PO-Revision-Date: 2026-10-06 09:57+0000\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: \n"
|
||||
|
||||
#. module: partner_email_sync_portal_login
|
||||
#. odoo-python
|
||||
#: code:addons/partner_email_sync_portal_login/models/res_partner.py:0
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Cannot change the email of %(partner)s: there is already a user with login "
|
||||
"%(login)s."
|
||||
msgstr ""
|
||||
"No se puede cambiar el email de %(partner)s: ya existe un usuario con el login "
|
||||
"%(login)s."
|
||||
|
||||
#. module: partner_email_sync_portal_login
|
||||
#: model:ir.model,name:partner_email_sync_portal_login.model_res_partner
|
||||
msgid "Contact"
|
||||
msgstr "Contacto"
|
||||
|
||||
#. module: partner_email_sync_portal_login
|
||||
#: model:ir.model,name:partner_email_sync_portal_login.model_portal_wizard_user
|
||||
msgid "Portal User Config"
|
||||
msgstr "Configuración de usuario de portal"
|
||||
|
||||
#. module: partner_email_sync_portal_login
|
||||
#. odoo-python
|
||||
#: code:addons/partner_email_sync_portal_login/models/res_partner.py:0
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Portal user login changed from %(old)s to %(new)s and a password reset email"
|
||||
" has been sent."
|
||||
msgstr ""
|
||||
"Login del usuario de portal cambiado de %(old)s a %(new)s y enviado el correo "
|
||||
"de restablecimiento de contraseña."
|
||||
|
||||
#. module: partner_email_sync_portal_login
|
||||
#. odoo-python
|
||||
#: code:addons/partner_email_sync_portal_login/models/res_partner.py:0
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Portal user login changed from %(old)s to %(new)s, but the password reset "
|
||||
"email could not be sent: %(error)s"
|
||||
msgstr ""
|
||||
"Login del usuario de portal cambiado de %(old)s a %(new)s, pero no se ha podido "
|
||||
"enviar el correo de restablecimiento de contraseña: %(error)s"
|
||||
|
||||
#. module: partner_email_sync_portal_login
|
||||
#. odoo-python
|
||||
#: code:addons/partner_email_sync_portal_login/models/res_partner.py:0
|
||||
#, python-format
|
||||
msgid "Portal user login changed from %(old)s to %(new)s."
|
||||
msgstr "Login del usuario de portal cambiado de %(old)s a %(new)s."
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * partner_email_sync_portal_login
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server 16.0\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-10-06 09:57+0000\n"
|
||||
"PO-Revision-Date: 2026-10-06 09:57+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: partner_email_sync_portal_login
|
||||
#. odoo-python
|
||||
#: code:addons/partner_email_sync_portal_login/models/res_partner.py:0
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Cannot change the email of %(partner)s: there is already a user with login "
|
||||
"%(login)s."
|
||||
msgstr ""
|
||||
|
||||
#. module: partner_email_sync_portal_login
|
||||
#: model:ir.model,name:partner_email_sync_portal_login.model_res_partner
|
||||
msgid "Contact"
|
||||
msgstr ""
|
||||
|
||||
#. module: partner_email_sync_portal_login
|
||||
#: model:ir.model,name:partner_email_sync_portal_login.model_portal_wizard_user
|
||||
msgid "Portal User Config"
|
||||
msgstr ""
|
||||
|
||||
#. module: partner_email_sync_portal_login
|
||||
#. odoo-python
|
||||
#: code:addons/partner_email_sync_portal_login/models/res_partner.py:0
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Portal user login changed from %(old)s to %(new)s and a password reset email"
|
||||
" has been sent."
|
||||
msgstr ""
|
||||
|
||||
#. module: partner_email_sync_portal_login
|
||||
#. odoo-python
|
||||
#: code:addons/partner_email_sync_portal_login/models/res_partner.py:0
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Portal user login changed from %(old)s to %(new)s, but the password reset "
|
||||
"email could not be sent: %(error)s"
|
||||
msgstr ""
|
||||
|
||||
#. module: partner_email_sync_portal_login
|
||||
#. odoo-python
|
||||
#: code:addons/partner_email_sync_portal_login/models/res_partner.py:0
|
||||
#, python-format
|
||||
msgid "Portal user login changed from %(old)s to %(new)s."
|
||||
msgstr ""
|
||||
1
partner_email_sync_portal_login/models/__init__.py
Normal file
1
partner_email_sync_portal_login/models/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import res_partner
|
||||
102
partner_email_sync_portal_login/models/res_partner.py
Normal file
102
partner_email_sync_portal_login/models/res_partner.py
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
# Copyright 2026 Criptomart
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
|
||||
|
||||
import logging
|
||||
|
||||
from markupsafe import Markup
|
||||
|
||||
from odoo import _, models
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tools import email_normalize
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ResPartner(models.Model):
|
||||
_inherit = "res.partner"
|
||||
|
||||
def write(self, vals):
|
||||
res = super().write(vals)
|
||||
if "email" in vals and not self.env.context.get("skip_portal_login_sync"):
|
||||
self._sync_portal_user_login()
|
||||
return res
|
||||
|
||||
def _get_portal_users_to_sync(self):
|
||||
self.ensure_one()
|
||||
group_portal = self.env.ref("base.group_portal")
|
||||
return self.sudo().user_ids.filtered(lambda u: group_portal in u.groups_id)
|
||||
|
||||
def _sync_portal_user_login(self):
|
||||
"""Set the partner email as login of its portal users and send them
|
||||
a password reset email."""
|
||||
Users = self.env["res.users"].sudo().with_context(active_test=False)
|
||||
for partner in self:
|
||||
users = partner._get_portal_users_to_sync()
|
||||
if not users:
|
||||
continue
|
||||
new_login = email_normalize(partner.email)
|
||||
if not new_login:
|
||||
_logger.info(
|
||||
"Portal login not synced for partner %s: invalid email %r",
|
||||
partner.id,
|
||||
partner.email,
|
||||
)
|
||||
continue
|
||||
for user in users:
|
||||
if email_normalize(user.login) == new_login:
|
||||
continue
|
||||
if Users.search_count(
|
||||
[("login", "=", new_login), ("id", "!=", user.id)]
|
||||
):
|
||||
raise UserError(
|
||||
_(
|
||||
"Cannot change the email of %(partner)s: there is "
|
||||
"already a user with login %(login)s.",
|
||||
partner=partner.display_name,
|
||||
login=new_login,
|
||||
)
|
||||
)
|
||||
old_login = user.login
|
||||
user.write({"login": new_login})
|
||||
_logger.info(
|
||||
"Portal user %s login changed from %s to %s",
|
||||
user.id,
|
||||
old_login,
|
||||
new_login,
|
||||
)
|
||||
if self.env.context.get("portal_login_sync_no_mail"):
|
||||
body = _(
|
||||
"Portal user login changed from %(old)s to %(new)s.",
|
||||
old=old_login,
|
||||
new=new_login,
|
||||
)
|
||||
else:
|
||||
body = partner._send_portal_login_reset_password(
|
||||
user, old_login, new_login
|
||||
)
|
||||
partner.sudo().message_post(body=Markup("<p>%s</p>") % body)
|
||||
|
||||
def _send_portal_login_reset_password(self, user, old_login, new_login):
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
user.action_reset_password()
|
||||
except Exception as e:
|
||||
# Do not block the email change if the reset email cannot be sent
|
||||
_logger.warning(
|
||||
"Password reset email for portal user %s could not be sent: %s",
|
||||
user.id,
|
||||
e,
|
||||
)
|
||||
return _(
|
||||
"Portal user login changed from %(old)s to %(new)s, but the "
|
||||
"password reset email could not be sent: %(error)s",
|
||||
old=old_login,
|
||||
new=new_login,
|
||||
error=str(e),
|
||||
)
|
||||
return _(
|
||||
"Portal user login changed from %(old)s to %(new)s and a password "
|
||||
"reset email has been sent.",
|
||||
old=old_login,
|
||||
new=new_login,
|
||||
)
|
||||
1
partner_email_sync_portal_login/tests/__init__.py
Normal file
1
partner_email_sync_portal_login/tests/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import test_partner_email_sync_portal_login
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
# Copyright 2026 Criptomart
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tests import TransactionCase, tagged
|
||||
|
||||
from odoo.addons.auth_signup.models.res_users import ResUsers
|
||||
|
||||
RESET_PASSWORD = "action_reset_password"
|
||||
|
||||
|
||||
@tagged("post_install", "-at_install")
|
||||
class TestPartnerEmailSyncPortalLogin(TransactionCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True))
|
||||
Users = cls.env["res.users"].with_context(no_reset_password=True)
|
||||
cls.portal_user = Users.create(
|
||||
{
|
||||
"name": "Portal Member",
|
||||
"login": "member@example.com",
|
||||
"email": "member@example.com",
|
||||
"groups_id": [(6, 0, [cls.env.ref("base.group_portal").id])],
|
||||
}
|
||||
)
|
||||
cls.internal_user = Users.create(
|
||||
{
|
||||
"name": "Internal Member",
|
||||
"login": "internal@example.com",
|
||||
"email": "internal@example.com",
|
||||
"groups_id": [(6, 0, [cls.env.ref("base.group_user").id])],
|
||||
}
|
||||
)
|
||||
|
||||
def test_portal_user_login_synced_and_reset_sent(self):
|
||||
with patch.object(ResUsers, RESET_PASSWORD, autospec=True) as reset:
|
||||
self.portal_user.partner_id.email = "New.Member@Example.com"
|
||||
self.assertEqual(self.portal_user.login, "new.member@example.com")
|
||||
reset.assert_called_once()
|
||||
self.assertEqual(reset.call_args.args[0], self.portal_user)
|
||||
|
||||
def test_internal_user_not_synced(self):
|
||||
with patch.object(ResUsers, RESET_PASSWORD, autospec=True) as reset:
|
||||
self.internal_user.partner_id.email = "other@example.com"
|
||||
self.assertEqual(self.internal_user.login, "internal@example.com")
|
||||
reset.assert_not_called()
|
||||
|
||||
def test_partner_without_user(self):
|
||||
partner = self.env["res.partner"].create(
|
||||
{"name": "No User", "email": "nouser@example.com"}
|
||||
)
|
||||
with patch.object(ResUsers, RESET_PASSWORD, autospec=True) as reset:
|
||||
partner.email = "nouser2@example.com"
|
||||
reset.assert_not_called()
|
||||
|
||||
def test_invalid_or_empty_email_keeps_login(self):
|
||||
with patch.object(ResUsers, RESET_PASSWORD, autospec=True) as reset:
|
||||
self.portal_user.partner_id.email = False
|
||||
self.portal_user.partner_id.email = "not-an-email"
|
||||
self.assertEqual(self.portal_user.login, "member@example.com")
|
||||
reset.assert_not_called()
|
||||
|
||||
def test_same_email_different_case_not_resent(self):
|
||||
with patch.object(ResUsers, RESET_PASSWORD, autospec=True) as reset:
|
||||
self.portal_user.partner_id.email = "MEMBER@example.com"
|
||||
self.assertEqual(self.portal_user.login, "member@example.com")
|
||||
reset.assert_not_called()
|
||||
|
||||
def test_login_already_used(self):
|
||||
with self.assertRaises(UserError):
|
||||
self.portal_user.partner_id.email = "internal@example.com"
|
||||
|
||||
def test_reset_email_failure_does_not_block(self):
|
||||
with patch.object(
|
||||
ResUsers, RESET_PASSWORD, autospec=True, side_effect=UserError("boom")
|
||||
):
|
||||
self.portal_user.partner_id.email = "fail@example.com"
|
||||
self.assertEqual(self.portal_user.login, "fail@example.com")
|
||||
|
||||
def test_skip_context(self):
|
||||
partner = self.portal_user.partner_id.with_context(
|
||||
skip_portal_login_sync=True
|
||||
)
|
||||
with patch.object(ResUsers, RESET_PASSWORD, autospec=True) as reset:
|
||||
partner.email = "skip@example.com"
|
||||
self.assertEqual(self.portal_user.login, "member@example.com")
|
||||
reset.assert_not_called()
|
||||
|
||||
def test_no_mail_context_syncs_login_only(self):
|
||||
partner = self.portal_user.partner_id.with_context(
|
||||
portal_login_sync_no_mail=True
|
||||
)
|
||||
with patch.object(ResUsers, RESET_PASSWORD, autospec=True) as reset:
|
||||
partner.email = "wizard@example.com"
|
||||
self.assertEqual(self.portal_user.login, "wizard@example.com")
|
||||
reset.assert_not_called()
|
||||
1
partner_email_sync_portal_login/wizard/__init__.py
Normal file
1
partner_email_sync_portal_login/wizard/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import portal_wizard
|
||||
14
partner_email_sync_portal_login/wizard/portal_wizard.py
Normal file
14
partner_email_sync_portal_login/wizard/portal_wizard.py
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
# Copyright 2026 Criptomart
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
|
||||
|
||||
from odoo import models
|
||||
|
||||
|
||||
class PortalWizardUser(models.TransientModel):
|
||||
_inherit = "portal.wizard.user"
|
||||
|
||||
def _update_partner_email(self):
|
||||
# The portal wizard already sends its own invitation email
|
||||
return super(
|
||||
PortalWizardUser, self.with_context(portal_login_sync_no_mail=True)
|
||||
)._update_partner_email()
|
||||
133
payment_redsys_preauth/README.rst
Normal file
133
payment_redsys_preauth/README.rst
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
===========================================
|
||||
Pasarela de pago Redsys con preautorización
|
||||
===========================================
|
||||
|
||||
..
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
!! This file is generated by oca-gen-addon-readme !!
|
||||
!! changes will be overwritten. !!
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
!! source digest: sha256:1eb9535d052a619aa545a383869076ca1dc1fade22746cb3fe13afa726c3e3a3
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
|
||||
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
|
||||
:target: https://odoo-community.org/page/development-status
|
||||
:alt: Beta
|
||||
.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png
|
||||
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
|
||||
:alt: License: AGPL-3
|
||||
.. |badge3| image:: https://img.shields.io/badge/github-Criptomart%2Ffoodcoopbcn-lightgray.png?logo=github
|
||||
:target: https://github.com/Criptomart/foodcoopbcn/tree/12.0/payment_redsys_preauth
|
||||
:alt: Criptomart/foodcoopbcn
|
||||
|
||||
|badge1| |badge2| |badge3|
|
||||
|
||||
Este módulo añade la opción de pago a través de la pasarela de Redsys con preautorización con tarjeta bancaria.
|
||||
|
||||
**Table of contents**
|
||||
|
||||
.. contents::
|
||||
:local:
|
||||
|
||||
Configuration
|
||||
=============
|
||||
|
||||
Parámetros
|
||||
~~~~~~~~~~
|
||||
|
||||
* **Nombre del comercio**: Indicaremos el nombre del comercio.
|
||||
|
||||
* **Número de comercio (FUC)**: Indicaremos el número de comercio que
|
||||
nuestra entidad nos ha comunicado.
|
||||
|
||||
* **Clave secreta de encriptación**: Indicaremos la clave de encriptación
|
||||
que tiene el comercio.
|
||||
|
||||
* **Número de terminal**: Indicaremos el terminal del TPV.
|
||||
|
||||
* **Tipo de firma**: Seleccionaremos el tipo de firma del comercio.
|
||||
|
||||
* **Tipo de moneda**: Seleccionaremos la moneda de nuestro terminal TPV
|
||||
(Normalmente EUR - Euros).
|
||||
|
||||
* **Tipo de transacción**: Indicaremos el tipo de transacción, 1.
|
||||
|
||||
* **Idioma TPV**: Indicaremos el idioma en el TPV.
|
||||
|
||||
* **Método de pago**: Indicaremos que tipo de pago se debe aceptar, pago con
|
||||
tarjeta, Bizum u otro de los disponibles.
|
||||
|
||||
* **URL_OK/URL_KO**: Durante el proceso del pago, y una vez que
|
||||
se muestra al cliente la pantalla con el resultado del mismo, es
|
||||
posible redirigir su navegador a una URL para las transacciones
|
||||
autorizadas y a otra si la transacción ha sido denegada. A éstas
|
||||
se las denomina URL_OK y URL_KO, respectivamente. Se trata
|
||||
de dos URLs que pueden ser proporcionadas por el comercio.
|
||||
|
||||
* **Porcentaje de pago**: Indicar el porcentaje de pago que se permite, si
|
||||
se deja a 0.0 se entiende 100%.
|
||||
|
||||
Nota
|
||||
~~~~
|
||||
|
||||
Se tiene que verificar la configuración del comercio en el
|
||||
módulo de administración de Redsys, donde la opción “Parámetros en las
|
||||
URLs” debe tener el valor “SI”.
|
||||
|
||||
En caso de que exista más de una base de datos en la instalación, cuando la
|
||||
pasarela de pago envía el formulario a "/payment/redsys/return" odoo no sabe
|
||||
con que base de datos procesar esta información, por lo que hay que establecer
|
||||
los parametros **dbfilter** y **dbname** en el archivo de configuración.
|
||||
|
||||
Para mostrar simultáneamente en el comercio electrónico varios de los métodos
|
||||
de pago que proporciona Redsys, como pago con tarjeta y Bizum por ejemplo,
|
||||
debemos duplicar el medio de pago y escoger en el campo método de pago el que
|
||||
corresponda en cada caso.
|
||||
|
||||
En los pagos con preautorización, el titular del TPV deberá solicitar al banco su activación.
|
||||
|
||||
|
||||
Known issues / Roadmap
|
||||
======================
|
||||
|
||||
De acuerdo a los requisitos de Redsys, el tamaño máximo del número de pedido
|
||||
resultante (``Ds_Order``) es de 12 caracteres. Este addon trunca dicho
|
||||
dato en el comienzo de la transacción, con el resultado de que, aunque Redsys
|
||||
la aceptará, no se finalizará el pedido pedido en Odoo al retornar y entregará
|
||||
un Error 500 a Redsys.
|
||||
Para evitar esto, es recomendable seleccionar secuencias de pedido que se
|
||||
ajusten a esta limitación, en particular de 10 o menos caracteres, al ser la
|
||||
referencia un número extendido a partir del pedido, que incluye un guion y un
|
||||
número secuencial para posibles repeticiones de pago.
|
||||
|
||||
Bug Tracker
|
||||
===========
|
||||
|
||||
Bugs are tracked on `GitHub Issues <https://github.com/Criptomart/foodcoopbcn/issues>`_.
|
||||
In case of trouble, please check there if your issue has already been reported.
|
||||
If you spotted it first, help us to smash it by providing a detailed and welcomed
|
||||
`feedback <https://github.com/Criptomart/foodcoopbcn/issues/new?body=module:%20payment_redsys_preauth%0Aversion:%2012.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.
|
||||
|
||||
Do not contact contributors directly about support or help with technical issues.
|
||||
|
||||
Credits
|
||||
=======
|
||||
|
||||
Authors
|
||||
~~~~~~~
|
||||
|
||||
* Criptomart
|
||||
|
||||
Contributors
|
||||
~~~~~~~~~~~~
|
||||
|
||||
* `Criptomart <https://www.criptomart.net>`_:
|
||||
|
||||
* Santi Noreña
|
||||
|
||||
Maintainers
|
||||
~~~~~~~~~~~
|
||||
|
||||
This module is part of the `Criptomart/foodcoopbcn <https://github.com/Criptomart/foodcoopbcn/tree/12.0/payment_redsys_preauth>`_ project on GitHub.
|
||||
|
||||
You are welcome to contribute.
|
||||
4
payment_redsys_preauth/__init__.py
Normal file
4
payment_redsys_preauth/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# License AGPL-3 - See https://www.gnu.org/licenses/agpl-3.0.html
|
||||
|
||||
from . import models
|
||||
from . import controllers
|
||||
15
payment_redsys_preauth/__manifest__.py
Normal file
15
payment_redsys_preauth/__manifest__.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# Copyright 2024 - 2025 Criptomart
|
||||
{
|
||||
"name": "Pasarela de pago Redsys con preautorización",
|
||||
"category": "Payment Acquirer",
|
||||
"summary": "Payment Acquirer: Redsys Implementation with Preauthorization",
|
||||
"version": "16.0.0.0.1",
|
||||
"author": "Criptomart",
|
||||
"website": "https://git.criptomart.net/foodcoopbcn",
|
||||
"depends": ["payment_redsys","sale_order_invoice_amount"],
|
||||
"data": [
|
||||
"data/payment_redsys.xml"
|
||||
],
|
||||
"license": "AGPL-3",
|
||||
"installable": True,
|
||||
}
|
||||
2
payment_redsys_preauth/controllers/__init__.py
Normal file
2
payment_redsys_preauth/controllers/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
|
||||
from . import main
|
||||
29
payment_redsys_preauth/controllers/main.py
Normal file
29
payment_redsys_preauth/controllers/main.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# © 2024 - 2025 criptomart.net
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
import logging
|
||||
import pprint
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
from odoo.addons.payment_redsys.controllers.main import RedsysController
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
||||
class RedsysController(RedsysController):
|
||||
_return_url = '/payment/redsys/return'
|
||||
_cancel_url = '/payment/redsys/cancel'
|
||||
_exception_url = '/payment/redsys/error'
|
||||
_reject_url = '/payment/redsys/reject'
|
||||
|
||||
|
||||
@http.route(['/payment/redsys/result/<page>'], type='http', auth='public',methods=['GET'], website=True)
|
||||
def redsys_result(self, page, **post):
|
||||
_logger.info('________ Redsys return sync: %s -- %s',
|
||||
pprint.pformat(post), page)
|
||||
# double check
|
||||
if post:
|
||||
request.env['payment.transaction'].sudo()._handle_notification_data('redsys', post)
|
||||
return request.redirect('/payment/status')
|
||||
26
payment_redsys_preauth/data/payment_redsys.xml
Normal file
26
payment_redsys_preauth/data/payment_redsys.xml
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Copyright 2024-2025 Criptomart
|
||||
License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl-3). -->
|
||||
<odoo noupdate="1">
|
||||
|
||||
<record id="payment_provider_redsys_preauth" model="payment.provider">
|
||||
<field name="name">Redsys Preautorización </field>
|
||||
<field name="image_128" type="base64" file="payment_redsys_preauth/static/description/icon.png"/>
|
||||
<field name="code">redsys</field>
|
||||
<field name="company_id" ref="base.main_company"/>
|
||||
<field name="redsys_merchant_name">Bussines name</field>
|
||||
<field name="redsys_merchant_code">8888888</field>
|
||||
<field name="redsys_merchant_description">Bussines description</field>
|
||||
<field name="redsys_secret_key">xxxxxxxxxxxx -- SHA256</field>
|
||||
<field name="redsys_terminal">001</field>
|
||||
<field name="redsys_transaction_type">1</field>
|
||||
<field name="module_id" ref="base.module_payment_redsys_preauth"/>
|
||||
<field name="pre_msg"><![CDATA[<p>You will be redirected to the Redsys payment gateway after clicking on the payment button.</p>]]></field>
|
||||
</record>
|
||||
|
||||
<record id="payment_icon_cc_bizum" model="payment.icon">
|
||||
<field name="name">Bizum</field>
|
||||
<field name="image" type="base64" file="payment_redsys_preauth/static/img/bizum.png"/>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
2
payment_redsys_preauth/models/__init__.py
Normal file
2
payment_redsys_preauth/models/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from . import payment_provider
|
||||
from . import payment_transaction
|
||||
135
payment_redsys_preauth/models/payment_provider.py
Normal file
135
payment_redsys_preauth/models/payment_provider.py
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
# © 2024 - 2025 criptomart
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
import logging
|
||||
import json
|
||||
import pprint
|
||||
import requests
|
||||
import urllib
|
||||
|
||||
from odoo import models, api, exceptions, fields
|
||||
from odoo.addons.payment.models.payment_provider import ValidationError
|
||||
from odoo.tools.float_utils import float_compare
|
||||
from odoo.tools.translate import _
|
||||
import odoo.exceptions
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PaymentProvider(models.Model):
|
||||
_inherit = 'payment.provider'
|
||||
|
||||
@api.depends('code')
|
||||
def _compute_feature_support_fields(self):
|
||||
super()._compute_feature_support_fields()
|
||||
self.update({
|
||||
'support_manual_capture': 'partial',
|
||||
})
|
||||
|
||||
def get_redsys_rest_urls(self, environment):
|
||||
""" Redsys Rest URLs
|
||||
"""
|
||||
if environment == 'prod':
|
||||
return {
|
||||
'redsys_rest_url':
|
||||
'https://sis.redsys.es/sis/rest/trataPeticionREST',
|
||||
}
|
||||
else:
|
||||
# redsys no acepta preautorizaciones en test, pero puede que cambie...
|
||||
return {
|
||||
'redsys_rest_url':
|
||||
'https://sis-t.redsys.es:25443/sis/rest/trataPeticionREST',
|
||||
}
|
||||
|
||||
def _prepare_merchant_parameters(self, tx_values):
|
||||
base_url = self._get_website_url()
|
||||
callback_url = "http://botiga.foodcoopbcn.cat"
|
||||
values = {
|
||||
"Ds_Sermepa_Url": self._redsys_get_api_url(),
|
||||
"Ds_Merchant_Amount": str(int(round(tx_values["amount"] * 100))),
|
||||
"Ds_Merchant_Currency": self.redsys_currency or "978",
|
||||
"Ds_Merchant_Order": (
|
||||
tx_values["reference"] and tx_values["reference"][-12:] or False
|
||||
),
|
||||
"Ds_Merchant_MerchantCode": (
|
||||
self.redsys_merchant_code and self.redsys_merchant_code[:9]
|
||||
),
|
||||
"Ds_Merchant_Terminal": self.redsys_terminal or "1",
|
||||
"Ds_Merchant_TransactionType": (self.redsys_transaction_type or "1"),
|
||||
"Ds_Merchant_Titular": tx_values.get(
|
||||
"billing_partner", self.env.user.partner_id
|
||||
).display_name[:60],
|
||||
"Ds_Merchant_MerchantName": (
|
||||
self.redsys_merchant_name and self.redsys_merchant_name[:25]
|
||||
),
|
||||
"Ds_Merchant_MerchantUrl": (
|
||||
"%s/payment/redsys/return" % (callback_url or base_url)
|
||||
)[:250],
|
||||
"Ds_Merchant_MerchantData": self.redsys_merchant_data or "",
|
||||
"Ds_Merchant_ProductDescription": (
|
||||
self.redsys_merchant_description
|
||||
and self.redsys_merchant_description[:125]
|
||||
),
|
||||
"Ds_Merchant_ConsumerLanguage": (self.redsys_merchant_lang or "001"),
|
||||
"Ds_Merchant_UrlOk": "%s/payment/redsys/result/redsys_result_ok" % base_url,
|
||||
"Ds_Merchant_UrlKo": "%s/payment/redsys/result/redsys_result_ko" % base_url,
|
||||
"Ds_Merchant_Paymethods": self.redsys_pay_method or "T",
|
||||
}
|
||||
return self._url_encode64(json.dumps(values)).decode("utf-8")
|
||||
|
||||
def _prepare_s2s_merchant_parameters(self, tx_values):
|
||||
values = {
|
||||
'Ds_Merchant_Amount': str(int(round(tx_values['amount'] * 100))),
|
||||
'Ds_Merchant_Currency': self.redsys_currency or '978',
|
||||
'Ds_Merchant_Order': (
|
||||
tx_values['reference'] and tx_values['reference'][-12:] or
|
||||
False),
|
||||
'Ds_Merchant_MerchantCode': (
|
||||
self.redsys_merchant_code and
|
||||
self.redsys_merchant_code[:9]),
|
||||
'Ds_Merchant_Terminal': self.redsys_terminal or '1',
|
||||
}
|
||||
if 'transaction_type' in tx_values:
|
||||
values.update({'Ds_Merchant_TransactionType': tx_values['transaction_type']})
|
||||
else:
|
||||
values.update({'Ds_Merchant_TransactionType':
|
||||
(self.redsys_transaction_type or '2')}),
|
||||
return self._url_encode64(json.dumps(values))
|
||||
|
||||
def redsys_s2s_create_transaction(self, values):
|
||||
self.ensure_one()
|
||||
redsys_values = dict()
|
||||
merchant_parameters = self._prepare_s2s_merchant_parameters(values)
|
||||
redsys_values.update({
|
||||
'Ds_SignatureVersion': str(self.redsys_signature_version),
|
||||
'Ds_MerchantParameters': merchant_parameters,
|
||||
'Ds_Signature': self.sign_parameters(
|
||||
self.redsys_secret_key, merchant_parameters),
|
||||
})
|
||||
return redsys_values
|
||||
|
||||
def redsys_send_s2s_msg(self, values):
|
||||
toSend = self.redsys_s2s_create_transaction(values)
|
||||
url = self.get_redsys_rest_urls('prod')['redsys_rest_url']
|
||||
_logger.info('Redsys: sending rest values:\n%s', pprint.pformat(toSend))
|
||||
try:
|
||||
if toSend and url:
|
||||
response = requests.post(url, data=toSend)
|
||||
else:
|
||||
raise exceptions.ValidationError('Error en los parámetros de la petición rest')
|
||||
except requests.exceptions.RequestException as e:
|
||||
_logger.error('Redsys: Error sending rest transaction %s:\n%s', values['reference'], str(e))
|
||||
return False
|
||||
if response and response.status_code != 200:
|
||||
_logger.error('Redsys: Error sending rest transaction %s: %s', values['reference'], response.status_code)
|
||||
return False
|
||||
_logger.info('Redsys: rest response:\n%s', pprint.pformat(response.text))
|
||||
try:
|
||||
errorCheck = response.json()
|
||||
if errorCheck.get('errorCode', ''):
|
||||
_logger.error('Redsys: Error recibido tx %s: %s', values['reference'], errorCheck.get('errorCode'))
|
||||
except json.JSONDecodeError:
|
||||
_logger.error('Error al decodificar la respuesta JSON para la transacción %s', values['reference'])
|
||||
return False
|
||||
return errorCheck
|
||||
|
||||
150
payment_redsys_preauth/models/payment_transaction.py
Normal file
150
payment_redsys_preauth/models/payment_transaction.py
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
# © 2024 - 2025 criptomart
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
import logging
|
||||
import json
|
||||
import pprint
|
||||
import requests
|
||||
|
||||
from odoo import models, api, exceptions, fields
|
||||
from odoo.addons.payment.models.payment_provider import ValidationError
|
||||
from odoo.tools.float_utils import float_compare
|
||||
from odoo.tools.translate import _
|
||||
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TxRedsysPreAuth(models.Model):
|
||||
_inherit = 'payment.transaction'
|
||||
|
||||
def _get_specific_rendering_values(self, processing_values):
|
||||
if self.provider_code != "redsys":
|
||||
return super()._get_specific_rendering_values(processing_values)
|
||||
redsys_values = dict(processing_values)
|
||||
merchant_parameters = self.provider_id._prepare_merchant_parameters(
|
||||
processing_values
|
||||
)
|
||||
redsys_values.update(
|
||||
{
|
||||
"api_url": self.provider_id._redsys_get_api_url(),
|
||||
"Ds_SignatureVersion": str(self.provider_id.redsys_signature_version),
|
||||
"Ds_MerchantParameters": merchant_parameters,
|
||||
"Ds_Signature": self.provider_id.sign_parameters(
|
||||
self.provider_id.redsys_secret_key, merchant_parameters
|
||||
),
|
||||
}
|
||||
)
|
||||
return redsys_values
|
||||
|
||||
# chequea que el usuario cancele el pago 9915. cambia el stado de done a authorized
|
||||
@api.model
|
||||
def _get_redsys_state(self, status_code):
|
||||
if 0 <= status_code <= 100:
|
||||
return "authorized"
|
||||
elif status_code <= 203:
|
||||
return "pending"
|
||||
elif status_code == 400:
|
||||
return "cancel"
|
||||
elif status_code == 900:
|
||||
return "done"
|
||||
elif 912 <= status_code <= 9915:
|
||||
return "cancel"
|
||||
else:
|
||||
return "error"
|
||||
|
||||
def _process_notification_data(self, data):
|
||||
if self.provider_code != "redsys":
|
||||
return super()._process_notification_data(data)
|
||||
params = self.merchant_params_json2dict(data)
|
||||
status_code = int(params.get('Ds_Response', '29999'))
|
||||
state = self._get_redsys_state(status_code)
|
||||
vals = {
|
||||
'state': state,
|
||||
'redsys_txnid': params.get('Ds_AuthorisationCode'),
|
||||
}
|
||||
state_message = ""
|
||||
feedback_error = False
|
||||
if state == 'done':
|
||||
vals['state_message'] = _('Ok: %s') % params.get('Ds_Response')
|
||||
self._set_done()
|
||||
self._finalize_post_processing()
|
||||
if state == 'authorized':
|
||||
vals['state_message'] = _('Auth Ok: %s') % params.get('Ds_Response')
|
||||
self._set_authorized()
|
||||
self._finalize_post_processing()
|
||||
elif state == 'pending': # 'Payment error: code: %s.'
|
||||
state_message = _("Redsys: feedback error: %s" %(status_code))
|
||||
self._set_pending()
|
||||
elif state == 'cancel': # 'Payment error: bank unavailable.'
|
||||
state_message = _("Redsys: feedback error: %s" %(status_code))
|
||||
self._set_canceled()
|
||||
else:
|
||||
state_message = _("Redsys: feedback error: %s" %(status_code))
|
||||
self._set_error(state_message)
|
||||
feedback_error = True
|
||||
if state_message:
|
||||
vals['state_message'] = state_message % {
|
||||
"status_code": params.get("Ds_Response"),
|
||||
"error_code": params.get("Ds_ErrorCode"),
|
||||
}
|
||||
if state == 'error':
|
||||
_logger.warning(vals['state_message'])
|
||||
self.write(vals)
|
||||
|
||||
def _send_capture_request(self, amount_to_capture=None):
|
||||
self.ensure_one()
|
||||
self._ensure_provider_is_not_disabled()
|
||||
_logger.info('Redsys: Capturing transaction %s -- %s', self.reference, amount_to_capture)
|
||||
try:
|
||||
if amount_to_capture:
|
||||
values = {'amount': amount_to_capture}
|
||||
else:
|
||||
values = {'amount': sum(order.invoiced_amount for order in self.sale_order_ids)}
|
||||
values.update({'reference': self.reference})
|
||||
values.update({'transaction_type': '2'})
|
||||
response = self.provider_id.redsys_send_s2s_msg(values)
|
||||
if not response:
|
||||
raise exceptions.ValidationError('Error mandando mensaje rest');
|
||||
if response.get('errorCode', ''):
|
||||
raise exceptions.ValidationError('Ref %s: Error Code %s' %(self.reference, response.get('errorCode')))
|
||||
params = self.merchant_params_json2dict(response)
|
||||
_logger.info('Redsys: parameters received:\n%s', pprint.pformat(params))
|
||||
if params['Ds_Response'] == '0900' and params['Ds_Order'] == self.reference:
|
||||
self.provider_reference = params['Ds_AuthorisationCode']
|
||||
self._set_done()
|
||||
_logger.info('Confirmacion de preautorizacion realizada con exito para la transaccion %s', self.reference)
|
||||
message = ('Preautorizacion confirmada')
|
||||
self.sale_order_ids.message_post(body=message)
|
||||
else:
|
||||
raise exceptions.ValidationError('Error en confirmacion de preautorizacion tx: %s - %s', self.reference, params['Ds_Response'])
|
||||
except Exception as e:
|
||||
_logger.exception('Redsys: Error capturando preautorización transacción %s:\n%s', self.reference, str(e))
|
||||
raise exceptions.UserError(_('Error capturing preauthorization for transaction %s:\n%s') % (self.reference, str(e)))
|
||||
|
||||
def _send_void_request(self, amount_to_void=None):
|
||||
self.ensure_one()
|
||||
self._ensure_provider_is_not_disabled()
|
||||
_logger.info('Redsys: Preauthorization cancel for transaction %s', self.reference)
|
||||
try:
|
||||
if amount_to_void:
|
||||
values = {'amount': amount_to_void}
|
||||
else:
|
||||
values = {'amount': self.amount}
|
||||
values.update({'reference': self.reference})
|
||||
values.update({'transaction_type': '9'})
|
||||
response = self.provider_id.redsys_send_s2s_msg(values)
|
||||
if not response:
|
||||
raise exceptions.ValidationError(_('error redsys_s2s_msg'))
|
||||
if response.get('errorCode', ''):
|
||||
raise exceptions.ValidationError('Ref %s: Error Code %s' % (self.reference, response.get('errorCode')))
|
||||
params = self.merchant_params_json2dict(response)
|
||||
_logger.info('Redsys: parameters received):\n%s', pprint.pformat(params))
|
||||
if params['Ds_Response'] == '0400' and params['Ds_Order'] == self.reference:
|
||||
self._set_canceled()
|
||||
_logger.info('Preautorización cancelada con éxito para la transacción %s', self.reference)
|
||||
else:
|
||||
raise exceptions.ValidationError('No se pudo cancelar la preautorización para la transacción %s - %s', self.reference, params['Ds_Response'])
|
||||
except Exception as e:
|
||||
_logger.exception('Redsys Error cancelando preautorización tx %s:\n%s', self.reference, str(e))
|
||||
raise exceptions.UserError(_('Error cancelling preauthorization for transaction %s:\n%s') % (self.reference, str(e)))
|
||||
54
payment_redsys_preauth/readme/CONFIGURE.rst
Normal file
54
payment_redsys_preauth/readme/CONFIGURE.rst
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
Parámetros
|
||||
~~~~~~~~~~
|
||||
|
||||
* **Nombre del comercio**: Indicaremos el nombre del comercio.
|
||||
|
||||
* **Número de comercio (FUC)**: Indicaremos el número de comercio que
|
||||
nuestra entidad nos ha comunicado.
|
||||
|
||||
* **Clave secreta de encriptación**: Indicaremos la clave de encriptación
|
||||
que tiene el comercio.
|
||||
|
||||
* **Número de terminal**: Indicaremos el terminal del TPV.
|
||||
|
||||
* **Tipo de firma**: Seleccionaremos el tipo de firma del comercio.
|
||||
|
||||
* **Tipo de moneda**: Seleccionaremos la moneda de nuestro terminal TPV
|
||||
(Normalmente EUR - Euros).
|
||||
|
||||
* **Tipo de transacción**: Indicaremos el tipo de transacción, 1.
|
||||
|
||||
* **Idioma TPV**: Indicaremos el idioma en el TPV.
|
||||
|
||||
* **Método de pago**: Indicaremos que tipo de pago se debe aceptar, pago con
|
||||
tarjeta, Bizum u otro de los disponibles.
|
||||
|
||||
* **URL_OK/URL_KO**: Durante el proceso del pago, y una vez que
|
||||
se muestra al cliente la pantalla con el resultado del mismo, es
|
||||
posible redirigir su navegador a una URL para las transacciones
|
||||
autorizadas y a otra si la transacción ha sido denegada. A éstas
|
||||
se las denomina URL_OK y URL_KO, respectivamente. Se trata
|
||||
de dos URLs que pueden ser proporcionadas por el comercio.
|
||||
|
||||
* **Porcentaje de pago**: Indicar el porcentaje de pago que se permite, si
|
||||
se deja a 0.0 se entiende 100%.
|
||||
|
||||
Nota
|
||||
~~~~
|
||||
|
||||
Se tiene que verificar la configuración del comercio en el
|
||||
módulo de administración de Redsys, donde la opción “Parámetros en las
|
||||
URLs” debe tener el valor “SI”.
|
||||
|
||||
En caso de que exista más de una base de datos en la instalación, cuando la
|
||||
pasarela de pago envía el formulario a "/payment/redsys/return" odoo no sabe
|
||||
con que base de datos procesar esta información, por lo que hay que establecer
|
||||
los parametros **dbfilter** y **dbname** en el archivo de configuración.
|
||||
|
||||
Para mostrar simultáneamente en el comercio electrónico varios de los métodos
|
||||
de pago que proporciona Redsys, como pago con tarjeta y Bizum por ejemplo,
|
||||
debemos duplicar el medio de pago y escoger en el campo método de pago el que
|
||||
corresponda en cada caso.
|
||||
|
||||
En los pagos con preautorización, el titular del TPV deberá solicitar al banco su activación.
|
||||
|
||||
3
payment_redsys_preauth/readme/CONTRIBUTORS.rst
Normal file
3
payment_redsys_preauth/readme/CONTRIBUTORS.rst
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
* `Criptomart <https://www.criptomart.net>`_:
|
||||
|
||||
* Santi Noreña
|
||||
1
payment_redsys_preauth/readme/DESCRIPTION.rst
Normal file
1
payment_redsys_preauth/readme/DESCRIPTION.rst
Normal file
|
|
@ -0,0 +1 @@
|
|||
Este módulo añade la opción de pago a través de la pasarela de Redsys con preautorización con tarjeta bancaria.
|
||||
0
payment_redsys_preauth/readme/INSTALL.rst
Normal file
0
payment_redsys_preauth/readme/INSTALL.rst
Normal file
9
payment_redsys_preauth/readme/ROADMAP.rst
Normal file
9
payment_redsys_preauth/readme/ROADMAP.rst
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
De acuerdo a los requisitos de Redsys, el tamaño máximo del número de pedido
|
||||
resultante (``Ds_Order``) es de 12 caracteres. Este addon trunca dicho
|
||||
dato en el comienzo de la transacción, con el resultado de que, aunque Redsys
|
||||
la aceptará, no se finalizará el pedido pedido en Odoo al retornar y entregará
|
||||
un Error 500 a Redsys.
|
||||
Para evitar esto, es recomendable seleccionar secuencias de pedido que se
|
||||
ajusten a esta limitación, en particular de 10 o menos caracteres, al ser la
|
||||
referencia un número extendido a partir del pedido, que incluye un guion y un
|
||||
número secuencial para posibles repeticiones de pago.
|
||||
BIN
payment_redsys_preauth/static/description/icon.png
Normal file
BIN
payment_redsys_preauth/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
|
|
@ -1,10 +1,9 @@
|
|||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||
<meta name="generator" content="Docutils 0.15.1: http://docutils.sourceforge.net/" />
|
||||
<title>Require Product Quantity in POS</title>
|
||||
<meta name="generator" content="Docutils: http://docutils.sourceforge.net/" />
|
||||
<title>Pasarela de pago Redsys con preautorización</title>
|
||||
<style type="text/css">
|
||||
|
||||
/*
|
||||
|
|
@ -360,63 +359,120 @@ ul.auto-toc {
|
|||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="document" id="require-product-quantity-in-pos">
|
||||
<h1 class="title">Require Product Quantity in POS</h1>
|
||||
<div class="document" id="pasarela-de-pago-redsys-con-preautorizacion">
|
||||
<h1 class="title">Pasarela de pago Redsys con preautorización</h1>
|
||||
|
||||
<!-- !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
!! This file is generated by oca-gen-addon-readme !!
|
||||
!! changes will be overwritten. !!
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
!! source digest: sha256:1eb9535d052a619aa545a383869076ca1dc1fade22746cb3fe13afa726c3e3a3
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
|
||||
<p><a class="reference external" href="https://odoo-community.org/page/development-status"><img alt="Beta" src="https://img.shields.io/badge/maturity-Beta-yellow.png" /></a> <a class="reference external" href="http://www.gnu.org/licenses/agpl-3.0-standalone.html"><img alt="License: AGPL-3" src="https://img.shields.io/badge/licence-AGPL--3-blue.png" /></a> <a class="reference external" href="https://github.com/OCA/pos/tree/14.0/pos_require_product_quantity"><img alt="OCA/pos" src="https://img.shields.io/badge/github-OCA%2Fpos-lightgray.png?logo=github" /></a> <a class="reference external" href="https://translation.odoo-community.org/projects/pos-14-0/pos-14-0-pos_require_product_quantity"><img alt="Translate me on Weblate" src="https://img.shields.io/badge/weblate-Translate%20me-F47D42.png" /></a> <a class="reference external" href="https://runbot.odoo-community.org/runbot/184/14.0"><img alt="Try me on Runbot" src="https://img.shields.io/badge/runbot-Try%20me-875A7B.png" /></a></p>
|
||||
<p>This module forbids adding empty order lines on POS orders.</p>
|
||||
<p>When clicking on the “Payment” button in the Point of Sale,
|
||||
a popup is shown if product quantity for one or more order
|
||||
lines is set to 0.</p>
|
||||
<p><a class="reference external" href="https://odoo-community.org/page/development-status"><img alt="Beta" src="https://img.shields.io/badge/maturity-Beta-yellow.png" /></a> <a class="reference external" href="http://www.gnu.org/licenses/agpl-3.0-standalone.html"><img alt="License: AGPL-3" src="https://img.shields.io/badge/licence-AGPL--3-blue.png" /></a> <a class="reference external" href="https://github.com/Criptomart/foodcoopbcn/tree/12.0/payment_redsys_preauth"><img alt="Criptomart/foodcoopbcn" src="https://img.shields.io/badge/github-Criptomart%2Ffoodcoopbcn-lightgray.png?logo=github" /></a></p>
|
||||
<p>Este módulo añade la opción de pago a través de la pasarela de Redsys con preautorización con tarjeta bancaria.</p>
|
||||
<p><strong>Table of contents</strong></p>
|
||||
<div class="contents local topic" id="contents">
|
||||
<ul class="simple">
|
||||
<li><a class="reference internal" href="#bug-tracker" id="id1">Bug Tracker</a></li>
|
||||
<li><a class="reference internal" href="#credits" id="id2">Credits</a><ul>
|
||||
<li><a class="reference internal" href="#authors" id="id3">Authors</a></li>
|
||||
<li><a class="reference internal" href="#contributors" id="id4">Contributors</a></li>
|
||||
<li><a class="reference internal" href="#maintainers" id="id5">Maintainers</a></li>
|
||||
<li><a class="reference internal" href="#configuration" id="id1">Configuration</a><ul>
|
||||
<li><a class="reference internal" href="#parametros" id="id2">Parámetros</a></li>
|
||||
<li><a class="reference internal" href="#nota" id="id3">Nota</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><a class="reference internal" href="#known-issues-roadmap" id="id4">Known issues / Roadmap</a></li>
|
||||
<li><a class="reference internal" href="#bug-tracker" id="id5">Bug Tracker</a></li>
|
||||
<li><a class="reference internal" href="#credits" id="id6">Credits</a><ul>
|
||||
<li><a class="reference internal" href="#authors" id="id7">Authors</a></li>
|
||||
<li><a class="reference internal" href="#contributors" id="id8">Contributors</a></li>
|
||||
<li><a class="reference internal" href="#maintainers" id="id9">Maintainers</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="configuration">
|
||||
<h1><a class="toc-backref" href="#id1">Configuration</a></h1>
|
||||
<div class="section" id="parametros">
|
||||
<h2><a class="toc-backref" href="#id2">Parámetros</a></h2>
|
||||
<ul class="simple">
|
||||
<li><strong>Nombre del comercio</strong>: Indicaremos el nombre del comercio.</li>
|
||||
<li><strong>Número de comercio (FUC)</strong>: Indicaremos el número de comercio que
|
||||
nuestra entidad nos ha comunicado.</li>
|
||||
<li><strong>Clave secreta de encriptación</strong>: Indicaremos la clave de encriptación
|
||||
que tiene el comercio.</li>
|
||||
<li><strong>Número de terminal</strong>: Indicaremos el terminal del TPV.</li>
|
||||
<li><strong>Tipo de firma</strong>: Seleccionaremos el tipo de firma del comercio.</li>
|
||||
<li><strong>Tipo de moneda</strong>: Seleccionaremos la moneda de nuestro terminal TPV
|
||||
(Normalmente EUR - Euros).</li>
|
||||
<li><strong>Tipo de transacción</strong>: Indicaremos el tipo de transacción, 1.</li>
|
||||
<li><strong>Idioma TPV</strong>: Indicaremos el idioma en el TPV.</li>
|
||||
<li><strong>Método de pago</strong>: Indicaremos que tipo de pago se debe aceptar, pago con
|
||||
tarjeta, Bizum u otro de los disponibles.</li>
|
||||
<li><strong>URL_OK/URL_KO</strong>: Durante el proceso del pago, y una vez que
|
||||
se muestra al cliente la pantalla con el resultado del mismo, es
|
||||
posible redirigir su navegador a una URL para las transacciones
|
||||
autorizadas y a otra si la transacción ha sido denegada. A éstas
|
||||
se las denomina URL_OK y URL_KO, respectivamente. Se trata
|
||||
de dos URLs que pueden ser proporcionadas por el comercio.</li>
|
||||
<li><strong>Porcentaje de pago</strong>: Indicar el porcentaje de pago que se permite, si
|
||||
se deja a 0.0 se entiende 100%.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="nota">
|
||||
<h2><a class="toc-backref" href="#id3">Nota</a></h2>
|
||||
<p>Se tiene que verificar la configuración del comercio en el
|
||||
módulo de administración de Redsys, donde la opción “Parámetros en las
|
||||
URLs” debe tener el valor “SI”.</p>
|
||||
<p>En caso de que exista más de una base de datos en la instalación, cuando la
|
||||
pasarela de pago envía el formulario a “/payment/redsys/return” odoo no sabe
|
||||
con que base de datos procesar esta información, por lo que hay que establecer
|
||||
los parametros <strong>dbfilter</strong> y <strong>dbname</strong> en el archivo de configuración.</p>
|
||||
<p>Para mostrar simultáneamente en el comercio electrónico varios de los métodos
|
||||
de pago que proporciona Redsys, como pago con tarjeta y Bizum por ejemplo,
|
||||
debemos duplicar el medio de pago y escoger en el campo método de pago el que
|
||||
corresponda en cada caso.</p>
|
||||
<p>En los pagos con preautorización, el titular del TPV deberá solicitar al banco su activación.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section" id="known-issues-roadmap">
|
||||
<h1><a class="toc-backref" href="#id4">Known issues / Roadmap</a></h1>
|
||||
<p>De acuerdo a los requisitos de Redsys, el tamaño máximo del número de pedido
|
||||
resultante (<tt class="docutils literal">Ds_Order</tt>) es de 12 caracteres. Este addon trunca dicho
|
||||
dato en el comienzo de la transacción, con el resultado de que, aunque Redsys
|
||||
la aceptará, no se finalizará el pedido pedido en Odoo al retornar y entregará
|
||||
un Error 500 a Redsys.
|
||||
Para evitar esto, es recomendable seleccionar secuencias de pedido que se
|
||||
ajusten a esta limitación, en particular de 10 o menos caracteres, al ser la
|
||||
referencia un número extendido a partir del pedido, que incluye un guion y un
|
||||
número secuencial para posibles repeticiones de pago.</p>
|
||||
</div>
|
||||
<div class="section" id="bug-tracker">
|
||||
<h1><a class="toc-backref" href="#id1">Bug Tracker</a></h1>
|
||||
<p>Bugs are tracked on <a class="reference external" href="https://github.com/OCA/pos/issues">GitHub Issues</a>.
|
||||
<h1><a class="toc-backref" href="#id5">Bug Tracker</a></h1>
|
||||
<p>Bugs are tracked on <a class="reference external" href="https://github.com/Criptomart/foodcoopbcn/issues">GitHub Issues</a>.
|
||||
In case of trouble, please check there if your issue has already been reported.
|
||||
If you spotted it first, help us smashing it by providing a detailed and welcomed
|
||||
<a class="reference external" href="https://github.com/OCA/pos/issues/new?body=module:%20pos_require_product_quantity%0Aversion:%2014.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**">feedback</a>.</p>
|
||||
If you spotted it first, help us to smash it by providing a detailed and welcomed
|
||||
<a class="reference external" href="https://github.com/Criptomart/foodcoopbcn/issues/new?body=module:%20payment_redsys_preauth%0Aversion:%2012.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**">feedback</a>.</p>
|
||||
<p>Do not contact contributors directly about support or help with technical issues.</p>
|
||||
</div>
|
||||
<div class="section" id="credits">
|
||||
<h1><a class="toc-backref" href="#id2">Credits</a></h1>
|
||||
<h1><a class="toc-backref" href="#id6">Credits</a></h1>
|
||||
<div class="section" id="authors">
|
||||
<h2><a class="toc-backref" href="#id3">Authors</a></h2>
|
||||
<h2><a class="toc-backref" href="#id7">Authors</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Coop IT Easy SCRLfs</li>
|
||||
<li>Criptomart</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="contributors">
|
||||
<h2><a class="toc-backref" href="#id4">Contributors</a></h2>
|
||||
<h2><a class="toc-backref" href="#id8">Contributors</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Robin Keunen <<a class="reference external" href="mailto:robin.keunen@coopiteasy.be">robin.keunen@coopiteasy.be</a>></li>
|
||||
<li>Iván Todorovich <<a class="reference external" href="mailto:ivan.todorovich@gmail.com">ivan.todorovich@gmail.com</a>></li>
|
||||
<li>Foram Shah <<a class="reference external" href="mailto:foram.shah@initos.com">foram.shah@initos.com</a>></li>
|
||||
<li><a class="reference external" href="https://www.criptomart.net">Criptomart</a>:<ul>
|
||||
<li>Santi Noreña</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="maintainers">
|
||||
<h2><a class="toc-backref" href="#id5">Maintainers</a></h2>
|
||||
<p>This module is maintained by the OCA.</p>
|
||||
<a class="reference external image-reference" href="https://odoo-community.org"><img alt="Odoo Community Association" src="https://odoo-community.org/logo.png" /></a>
|
||||
<p>OCA, or the Odoo Community Association, is a nonprofit organization whose
|
||||
mission is to support the collaborative development of Odoo features and
|
||||
promote its widespread use.</p>
|
||||
<p>This module is part of the <a class="reference external" href="https://github.com/OCA/pos/tree/14.0/pos_require_product_quantity">OCA/pos</a> project on GitHub.</p>
|
||||
<p>You are welcome to contribute. To learn how please visit <a class="reference external" href="https://odoo-community.org/page/Contribute">https://odoo-community.org/page/Contribute</a>.</p>
|
||||
<h2><a class="toc-backref" href="#id9">Maintainers</a></h2>
|
||||
<p>This module is part of the <a class="reference external" href="https://github.com/Criptomart/foodcoopbcn/tree/12.0/payment_redsys_preauth">Criptomart/foodcoopbcn</a> project on GitHub.</p>
|
||||
<p>You are welcome to contribute.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
BIN
payment_redsys_preauth/static/img/bizum.png
Normal file
BIN
payment_redsys_preauth/static/img/bizum.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.8 KiB |
BIN
payment_redsys_preauth/static/src/img/redsys_icon.png
Normal file
BIN
payment_redsys_preauth/static/src/img/redsys_icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
3
pos_cashdro_allow_manual/__init__.py
Normal file
3
pos_cashdro_allow_manual/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Copyright 2026 Criptomart
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
|
||||
from . import models
|
||||
23
pos_cashdro_allow_manual/__manifest__.py
Normal file
23
pos_cashdro_allow_manual/__manifest__.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# Copyright 2026 Criptomart
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
|
||||
{
|
||||
"name": "POS CashDro Allow Manual Amount",
|
||||
"summary": "Permite introducir manualmente el importe en pagos CashDro",
|
||||
"version": "16.0.1.0.0",
|
||||
"category": "Point of Sale",
|
||||
"license": "AGPL-3",
|
||||
"author": "Criptomart",
|
||||
"depends": [
|
||||
"pos_payment_method_cashdro",
|
||||
],
|
||||
"data": [
|
||||
"views/pos_config_views.xml",
|
||||
],
|
||||
"assets": {
|
||||
"point_of_sale.assets": [
|
||||
"pos_cashdro_allow_manual/static/src/js/PaymentScreen.esm.js",
|
||||
"pos_cashdro_allow_manual/static/src/xml/PaymentScreenPaymentLines.xml",
|
||||
],
|
||||
},
|
||||
"installable": True,
|
||||
}
|
||||
4
pos_cashdro_allow_manual/models/__init__.py
Normal file
4
pos_cashdro_allow_manual/models/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# Copyright 2026 Criptomart
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
|
||||
from . import pos_config
|
||||
from . import res_config_settings
|
||||
18
pos_cashdro_allow_manual/models/pos_config.py
Normal file
18
pos_cashdro_allow_manual/models/pos_config.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# Copyright 2026 Criptomart
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class PosConfig(models.Model):
|
||||
_inherit = "pos.config"
|
||||
|
||||
cashdro_allow_manual_amount = fields.Boolean(
|
||||
string="Permitir importe manual CashDro",
|
||||
help=(
|
||||
"Cuando está activado, aparece un botón 'Manual' en la pantalla de "
|
||||
"pago del TPV que permite confirmar el importe de la línea sin pasar "
|
||||
"por la máquina CashDro. Útil ante fallos de comunicación, "
|
||||
"devoluciones u otras situaciones de emergencia."
|
||||
),
|
||||
default=True,
|
||||
)
|
||||
12
pos_cashdro_allow_manual/models/res_config_settings.py
Normal file
12
pos_cashdro_allow_manual/models/res_config_settings.py
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# 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"
|
||||
|
||||
pos_cashdro_allow_manual_amount = fields.Boolean(
|
||||
related="pos_config_id.cashdro_allow_manual_amount",
|
||||
readonly=False,
|
||||
)
|
||||
45
pos_cashdro_allow_manual/static/src/js/PaymentScreen.esm.js
Normal file
45
pos_cashdro_allow_manual/static/src/js/PaymentScreen.esm.js
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
/** @odoo-module **/
|
||||
// Copyright 2026 Criptomart
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
|
||||
|
||||
import {Component} from "point_of_sale.Registries";
|
||||
import PaymentScreen from "point_of_sale.PaymentScreen";
|
||||
import {useListener} from "@web/core/utils/hooks";
|
||||
|
||||
const CashdroAllowManualPaymentScreen = (OriginalPaymentScreen) =>
|
||||
class extends OriginalPaymentScreen {
|
||||
setup() {
|
||||
super.setup();
|
||||
useListener("cashdro-send-manual", this._cashdroSendManual);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the Cashdro payment line as done using the amount that is already
|
||||
* set on the line (entered manually via the numpad), without contacting
|
||||
* the CashDro machine.
|
||||
*
|
||||
* Sets `cashdro_manual_done = true` on the line so the delete button
|
||||
* remains visible even in `done` state, letting the cashier undo the
|
||||
* operation in case of a mistake.
|
||||
*
|
||||
* Refuses to proceed when the amount is 0 to avoid locking the POS with
|
||||
* an undeletable zero-amount payment line.
|
||||
*/
|
||||
async _cashdroSendManual({detail: line}) {
|
||||
if (!line.get_amount()) {
|
||||
this.showPopup("ErrorPopup", {
|
||||
title: this.env._t("Importe no válido"),
|
||||
body: this.env._t(
|
||||
"Introduzca el importe en el teclado numérico antes de confirmar manualmente el pago."
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Flag this line as manually confirmed so the template keeps
|
||||
// showing its delete button even after the status is 'done'.
|
||||
line.cashdro_manual_done = true;
|
||||
line.set_payment_status("done");
|
||||
}
|
||||
};
|
||||
|
||||
Component.extend(PaymentScreen, CashdroAllowManualPaymentScreen);
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!-- Copyright 2026 Criptomart
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
|
||||
|
||||
Extends the PaymentScreenPaymentLines template to add a "Manual" button
|
||||
next to the Send/Retry action buttons on Cashdro payment lines.
|
||||
|
||||
The button is shown when:
|
||||
- the payment method is of type "cashdro"
|
||||
- cashdro_allow_manual_amount is True on the pos.config
|
||||
- the line is in "pending" or "retry" state (the two states where
|
||||
the numpad is already active and the cashier can edit the amount)
|
||||
|
||||
Clicking it triggers the "cashdro-send-manual" event which is handled
|
||||
by CashdroAllowManualPaymentScreen in PaymentScreen.esm.js.
|
||||
-->
|
||||
<templates id="template" xml:space="preserve">
|
||||
|
||||
<t
|
||||
t-name="pos_cashdro_allow_manual.PaymentScreenPaymentLines"
|
||||
t-inherit="point_of_sale.PaymentScreenPaymentLines"
|
||||
t-inherit-mode="extension"
|
||||
owl="1"
|
||||
>
|
||||
<!--
|
||||
//div[hasclass('send_payment_request')] matches two nodes:
|
||||
1. the "Send" button shown in the "pending" state
|
||||
2. the "Retry" button shown in the "retry" state
|
||||
position="after" inserts our node after each match,
|
||||
so the Manual button appears in both states.
|
||||
-->
|
||||
<xpath expr="//div[hasclass('send_payment_request')]" position="before">
|
||||
<t
|
||||
t-if="line.payment_method.use_payment_terminal === 'cashdro'
|
||||
and env.pos.config.cashdro_allow_manual_amount"
|
||||
>
|
||||
<div
|
||||
class="button cashdro-manual-button"
|
||||
style="flex: 0 0 auto; padding: 0 12px; font-size: 0.8em; opacity: 0.75;"
|
||||
title="Confirmar importe manualmente sin usar la máquina CashDro"
|
||||
t-on-click="() => this.trigger('cashdro-send-manual', line)"
|
||||
>
|
||||
Manual
|
||||
</div>
|
||||
</t>
|
||||
</xpath>
|
||||
<!--
|
||||
Extend the t-if on the wrapper <t> that controls the delete button
|
||||
visibility so that manually-confirmed Cashdro lines keep showing the
|
||||
delete button even in 'done' state. This lets the cashier undo
|
||||
an accidental Manual press without blocking the POS session.
|
||||
|
||||
The targeted element is the <t t-if="..."> that wraps the
|
||||
delete-button <div> inside the selected payment line block.
|
||||
-->
|
||||
<xpath
|
||||
expr="//t[@t-if='line.selected']/div/t[div[hasclass('delete-button')]]"
|
||||
position="attributes"
|
||||
>
|
||||
<attribute name="t-if">!line.payment_status or !['done', 'reversed', 'waitingCard', 'waitingCapture'].includes(line.payment_status) or line.cashdro_manual_done</attribute>
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
28
pos_cashdro_allow_manual/views/pos_config_views.xml
Normal file
28
pos_cashdro_allow_manual/views/pos_config_views.xml
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<?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_cashdro_allow_manual_form" model="ir.ui.view">
|
||||
<field name="name">res.config.settings.cashdro.allow.manual.form</field>
|
||||
<field name="model">res.config.settings</field>
|
||||
<field name="inherit_id" ref="point_of_sale.res_config_settings_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//div[@id='payment_methods_new']" position="after">
|
||||
<div class="col-12 col-lg-6 o_setting_box">
|
||||
<div class="o_setting_left_pane">
|
||||
<field name="pos_cashdro_allow_manual_amount"/>
|
||||
</div>
|
||||
<div class="o_setting_right_pane">
|
||||
<label for="pos_cashdro_allow_manual_amount"
|
||||
string="Permitir importe manual CashDro"/>
|
||||
<div class="text-muted">
|
||||
Muestra un botón "Manual" en la pantalla de pago para
|
||||
confirmar el importe CashDro sin pasar por la máquina.
|
||||
Útil ante fallos de comunicación o situaciones de emergencia.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
2
pos_cashdro_refund/__init__.py
Normal file
2
pos_cashdro_refund/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# Copyright 2026 Criptomart
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
|
||||
21
pos_cashdro_refund/__manifest__.py
Normal file
21
pos_cashdro_refund/__manifest__.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# Copyright 2026 Criptomart
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
|
||||
{
|
||||
"name": "POS CashDro Refund",
|
||||
"summary": "Permite devoluciones (payouts) a través de la máquina CashDro desde el TPV",
|
||||
"version": "16.0.1.0.0",
|
||||
"category": "Point of Sale",
|
||||
"license": "AGPL-3",
|
||||
"author": "Criptomart",
|
||||
"depends": [
|
||||
"pos_payment_method_cashdro",
|
||||
],
|
||||
"assets": {
|
||||
"point_of_sale.assets": [
|
||||
"pos_cashdro_refund/static/src/js/PasswordInputPopup.js",
|
||||
"pos_cashdro_refund/static/src/js/payment_cashdro_refund.esm.js",
|
||||
"pos_cashdro_refund/static/src/xml/PasswordInputPopup.xml",
|
||||
],
|
||||
},
|
||||
"installable": True,
|
||||
}
|
||||
38
pos_cashdro_refund/static/src/js/PasswordInputPopup.js
Normal file
38
pos_cashdro_refund/static/src/js/PasswordInputPopup.js
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
odoo.define("pos_cashdro_refund.PasswordInputPopup", function (require) {
|
||||
"use strict";
|
||||
/* Copyright 2026 Criptomart - extracted from pos_payment_method_cashdro
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). */
|
||||
|
||||
const AbstractAwaitablePopup = require("point_of_sale.AbstractAwaitablePopup");
|
||||
const Registries = require("point_of_sale.Registries");
|
||||
const {_lt} = require("@web/core/l10n/translation");
|
||||
const {onMounted, useRef, useState} = owl;
|
||||
|
||||
class PasswordInputPopup extends AbstractAwaitablePopup {
|
||||
setup() {
|
||||
super.setup();
|
||||
this.state = useState({inputValue: this.props.startingValue});
|
||||
this.inputRef = useRef("input");
|
||||
onMounted(this.onMounted);
|
||||
}
|
||||
onMounted() {
|
||||
this.inputRef.el.focus();
|
||||
}
|
||||
getPayload() {
|
||||
return this.state.inputValue;
|
||||
}
|
||||
}
|
||||
PasswordInputPopup.template = "PasswordInputPopup";
|
||||
PasswordInputPopup.defaultProps = {
|
||||
confirmText: _lt("Ok"),
|
||||
cancelText: _lt("Cancel"),
|
||||
title: "",
|
||||
body: "",
|
||||
startingValue: "",
|
||||
placeholder: "",
|
||||
};
|
||||
|
||||
Registries.Component.add(PasswordInputPopup);
|
||||
|
||||
return PasswordInputPopup;
|
||||
});
|
||||
120
pos_cashdro_refund/static/src/js/payment_cashdro_refund.esm.js
Normal file
120
pos_cashdro_refund/static/src/js/payment_cashdro_refund.esm.js
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
/** @odoo-module */
|
||||
/* Copyright 2026 Criptomart - extracted from pos_payment_method_cashdro
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
|
||||
|
||||
Adds refund (payout) support to the CashDro payment terminal integration.
|
||||
When the order due amount is negative (i.e. a return/refund), this module:
|
||||
1. Asks the cashier for the CashDro password to authorise the payout.
|
||||
2. Sends a type=3 (payout) request to the CashDro instead of the normal
|
||||
type=4 (collection) request.
|
||||
3. Sets the payment line amount to the negative amount dispensed by the
|
||||
machine (totalout).
|
||||
Normal (positive) payments are delegated unchanged to the original
|
||||
cashdro_send_payment_request method.
|
||||
*/
|
||||
|
||||
import {PaymentCashdro} from "@pos_payment_method_cashdro/js/payment_cashdro.esm";
|
||||
import {Gui} from "point_of_sale.Gui";
|
||||
import {_t} from "web.core";
|
||||
|
||||
/**
|
||||
* Dedicated refund (payout) handler added to the CashDro prototype.
|
||||
*
|
||||
* Keeping this as a SEPARATE named method (rather than inlining it inside an
|
||||
* override of cashdro_send_payment_request) means that pos_cashdro_rounding
|
||||
* can always delegate refunds here regardless of which module loaded last.
|
||||
* Both modules patch cashdro_send_payment_request directly on the prototype,
|
||||
* so the last writer wins for that method; but _cashdro_send_refund_request is
|
||||
* only ever written by this module and is never overwritten.
|
||||
*/
|
||||
PaymentCashdro.prototype._cashdro_send_refund_request = async function (order) {
|
||||
const payment_line = order.selected_paymentline;
|
||||
const due = order.get_due(payment_line);
|
||||
// Include cash rounding if applied (same logic as pos_cashdro_rounding for
|
||||
// normal payments). get_rounding_applied() returns 0 when no rounding is
|
||||
// configured, so this is safe without pos_cashdro_rounding installed.
|
||||
const rounding = order.get_rounding_applied ? order.get_rounding_applied() : 0;
|
||||
const amount = Math.round(Math.abs(due + rounding) * 100);
|
||||
|
||||
// Require password authorisation before opening the drawer for a payout.
|
||||
const {confirmed, payload: password} = await Gui.showPopup(
|
||||
"PasswordInputPopup",
|
||||
{
|
||||
title: _t("CashDro Refund Authorization"),
|
||||
body: _t("Enter the CashDro password to authorize this refund"),
|
||||
placeholder: _t("Password"),
|
||||
}
|
||||
);
|
||||
if (!confirmed) {
|
||||
payment_line.set_payment_status("retry");
|
||||
return false;
|
||||
}
|
||||
const method = payment_line.payment_method;
|
||||
if (password !== method.cashdro_password) {
|
||||
await Gui.showPopup("ErrorPopup", {
|
||||
title: _t("Authentication Failed"),
|
||||
body: _t("The CashDro password is incorrect."),
|
||||
});
|
||||
payment_line.set_payment_status("retry");
|
||||
return false;
|
||||
}
|
||||
|
||||
const start_url = this._cashdro_payout_url({amount: amount});
|
||||
console.log(start_url);
|
||||
const res = await this._cashdro_request(start_url);
|
||||
console.log(res);
|
||||
const operation_id = res.data || "";
|
||||
this.pos.get_order().cashdro_operation = operation_id;
|
||||
|
||||
const ack_url = this._cashdro_ack_url(operation_id);
|
||||
const res_ack = await this._cashdro_request(ack_url);
|
||||
console.log(res_ack);
|
||||
|
||||
const ask_url = this._cashdro_ask_url(operation_id);
|
||||
const operation_data = await this._cashdro_request_payment(ask_url);
|
||||
console.log(operation_data);
|
||||
|
||||
const data = JSON.parse(operation_data.data);
|
||||
payment_line.cashdro_operation_data = data;
|
||||
// CashDro reports the dispensed amount in totalout for payouts.
|
||||
// The API may return totalout as a negative value (money going out),
|
||||
// so we use Math.abs to get the magnitude and explicitly set the
|
||||
// payment line to a negative amount (refund to customer).
|
||||
const dispensed = Math.abs(data.operation.totalout) / 100;
|
||||
payment_line.set_amount(-dispensed);
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the URL for a type=3 (payout/refund) operation on the CashDro.
|
||||
*/
|
||||
PaymentCashdro.prototype._cashdro_payout_url = function (parameters) {
|
||||
const user = this.pos.get_cashier().id || this.pos.user.id;
|
||||
let url = `${this._cashdro_url()}&operation=startOperation&type=3`;
|
||||
url += `&posid=pos-${this.pos.pos_session.name}`;
|
||||
url += `&posuser=${user}`;
|
||||
url += `¶meters=${encodeURIComponent(JSON.stringify(parameters))}`;
|
||||
return url;
|
||||
};
|
||||
|
||||
/**
|
||||
* Override cashdro_send_payment_request to route refunds to
|
||||
* _cashdro_send_refund_request.
|
||||
*
|
||||
* NOTE: if pos_cashdro_rounding is also installed it will overwrite this
|
||||
* override (because "rounding" > "refund" alphabetically), but that is fine
|
||||
* because pos_cashdro_rounding also delegates negative amounts to
|
||||
* _cashdro_send_refund_request. This override only matters when the rounding
|
||||
* module is NOT installed.
|
||||
*/
|
||||
const _originalSendPaymentRequest =
|
||||
PaymentCashdro.prototype.cashdro_send_payment_request;
|
||||
|
||||
PaymentCashdro.prototype.cashdro_send_payment_request = async function (order) {
|
||||
const payment_line = order.selected_paymentline;
|
||||
const due = order.get_due(payment_line);
|
||||
if (due < 0) {
|
||||
return this._cashdro_send_refund_request(order);
|
||||
}
|
||||
return _originalSendPaymentRequest.call(this, order);
|
||||
};
|
||||
33
pos_cashdro_refund/static/src/xml/PasswordInputPopup.xml
Normal file
33
pos_cashdro_refund/static/src/xml/PasswordInputPopup.xml
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Copyright 2026 Criptomart - extracted from pos_payment_method_cashdro
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -->
|
||||
<templates id="template" xml:space="preserve">
|
||||
|
||||
<t t-name="PasswordInputPopup" owl="1">
|
||||
<div class="popup popup-textinput">
|
||||
<header class="title">
|
||||
<t t-esc="props.title" />
|
||||
</header>
|
||||
<div class="body">
|
||||
<p>
|
||||
<t t-esc="props.body" />
|
||||
</p>
|
||||
<input
|
||||
type="password"
|
||||
t-model="state.inputValue"
|
||||
t-ref="input"
|
||||
t-att-placeholder="props.placeholder"
|
||||
/>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<div class="button confirm highlight" t-on-click="confirm">
|
||||
<t t-esc="props.confirmText" />
|
||||
</div>
|
||||
<div class="button cancel" t-on-click="cancel">
|
||||
<t t-esc="props.cancelText" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -11,6 +11,17 @@ import { PaymentCashdro } from "@pos_payment_method_cashdro/js/payment_cashdro.e
|
|||
PaymentCashdro.prototype.cashdro_send_payment_request = async function (order) {
|
||||
const payment_line = order.selected_paymentline;
|
||||
try {
|
||||
// If pos_cashdro_refund is installed this method handles payouts.
|
||||
// Rounding is not applicable to refunds so we delegate and return.
|
||||
const due = order.get_due(payment_line);
|
||||
if (due < 0) {
|
||||
if (this._cashdro_send_refund_request) {
|
||||
return this._cashdro_send_refund_request(order);
|
||||
}
|
||||
// Refund module not installed – nothing we can do.
|
||||
payment_line.set_payment_status("retry");
|
||||
return false;
|
||||
}
|
||||
// Cashdro treats decimals as positions in an integer we also have
|
||||
// to deal with floating point computing to avoid decimals at the
|
||||
// end or the drawer will reject our request.
|
||||
|
|
|
|||
29
pos_dashboard_cooperative/README.md
Normal file
29
pos_dashboard_cooperative/README.md
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# POS Dashboard Cooperative
|
||||
|
||||
Real-time BI dashboards for cooperative supermarkets, built on top of
|
||||
Point of Sale data.
|
||||
|
||||
This module extends `report.pos.order` with three new dimensions:
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| `is_member_at_date` | True if the customer held a `share.line` whose `effective_date` was on or before the order date. |
|
||||
| `categ_root_id` | Product family (root of `product.category` parent_path). |
|
||||
| `categ_parent_id` | Product subfamily (parent of the leaf category). |
|
||||
| `date_day` | Order date truncated to day. |
|
||||
| `hour_of_day` | Hour of the order (`EXTRACT(HOUR FROM date_order)`). |
|
||||
| `day_of_week` | Day of week, 1 = Monday … 7 = Sunday (ISO). |
|
||||
|
||||
Pre-configured menus under *Point of Sale → Reporting → Cooperative
|
||||
Dashboards* cover overview, members vs. non-members, categories, top
|
||||
products, and time distributions. Four tiles surface the headline KPIs
|
||||
on the `web_dashboard_tile` board.
|
||||
|
||||
For spreadsheet dashboards published in Odoo's *Dashboards* menu, install
|
||||
the companion module `spreadsheet_dashboard_pos_cooperative`.
|
||||
|
||||
See `readme/` for the OCA-style structured documentation.
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0 or later.
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue