308 lines
12 KiB
Python
308 lines
12 KiB
Python
# 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,
|
|
),
|
|
}
|