hr_timesheet_sheet* fixes: back to list after remove a timesheet. bulk unfreeze confirmed and pending sheets

This commit is contained in:
Luis 2026-10-01 09:38:17 +02:00
parent 8b0d0bb6cc
commit 0323b6672e
11 changed files with 354 additions and 34 deletions

View file

@ -304,5 +304,9 @@ class Sheet(models.Model):
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,
),
}

View file

@ -8,6 +8,13 @@ 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

View file

@ -44,7 +44,7 @@ class TestHrTimesheetSheetDeleteCascade(TransactionCase):
cls.employee = cls._new_employee("Cascade Employee", cls.user)
cls.employee_other = cls._new_employee("Other Employee", cls.user_other)
cls.employee_span = cls._new_employee("Span Employee", cls.user_span)
cls._new_employee("Cascade Manager", cls.user_manager)
cls.employee_manager = cls._new_employee("Cascade Manager", cls.user_manager)
cls.project = cls.env["project.project"].create(
{
@ -215,6 +215,16 @@ class TestHrTimesheetSheetDeleteCascade(TransactionCase):
.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()
@ -357,6 +367,63 @@ class TestHrTimesheetSheetDeleteCascade(TransactionCase):
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):

View file

@ -107,7 +107,46 @@ class HrTimesheetSheetDeleteWizard(models.TransientModel):
)
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 {"type": "ir.actions.act_window_close"}
return next_action

View file

@ -55,6 +55,11 @@
name="binding_model_id"
ref="hr_timesheet_sheet.model_hr_timesheet_sheet"
/>
<field name="binding_view_types">list,kanban,form</field>
<!--
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>