# 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, ), }