add hr_timesheet_sheet_no_review, hr_timesheet_sheet_holidays, hr_timesheet_sheet_delete_cascade
This commit is contained in:
parent
b07082f70d
commit
8b0d0bb6cc
53 changed files with 3953 additions and 0 deletions
308
hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py
Normal file
308
hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
# Copyright 2026 Criptomart
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
|
||||
|
||||
from odoo import SUPERUSER_ID, _, models
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tools.misc import format_date, format_duration
|
||||
|
||||
REFUSABLE_LEAVE_STATES = ("draft", "confirm", "validate", "validate1")
|
||||
|
||||
|
||||
class Sheet(models.Model):
|
||||
_inherit = "hr_timesheet.sheet"
|
||||
|
||||
def _sheet_delete_cascade_token(self):
|
||||
"""Hook for extensions"""
|
||||
return self.env["account.analytic.line"]._sheet_delete_cascade_token()
|
||||
|
||||
def _with_delete_cascade(self):
|
||||
"""Hook for extensions
|
||||
|
||||
The deliberate way to run the cascade from code. It is a private
|
||||
method on purpose: a name starting with an underscore cannot be
|
||||
reached through call_kw, so the sentinel cannot be obtained remotely.
|
||||
"""
|
||||
token = self._sheet_delete_cascade_token()
|
||||
return self.with_context(sheet_delete_cascade=token)
|
||||
|
||||
def _in_delete_cascade(self):
|
||||
return (
|
||||
self.env.context.get("sheet_delete_cascade")
|
||||
is self._sheet_delete_cascade_token()
|
||||
)
|
||||
|
||||
def _sheet_leave_policy(self):
|
||||
"""Hook for extensions
|
||||
|
||||
The policy in force for this recordset: ``cancel`` as soon as one
|
||||
company asks for it, so that a summary never understates what is about
|
||||
to happen. The destructive path works sheet by sheet, where the
|
||||
recordset holds a single sheet and the answer is exact.
|
||||
"""
|
||||
companies = self.company_id or self.env.company
|
||||
policies = companies.mapped("timesheet_sheet_delete_leave_policy")
|
||||
return "cancel" if "cancel" in policies else "detach"
|
||||
|
||||
def _delete_cascade_cancel_sheets(self):
|
||||
"""Hook for extensions"""
|
||||
return self.filtered(lambda sheet: sheet._sheet_leave_policy() == "cancel")
|
||||
|
||||
def _sheet_leave_lines(self):
|
||||
"""Hook for extensions"""
|
||||
return self.mapped("timesheet_ids").filtered("holiday_id")
|
||||
|
||||
def _sheet_leaves(self):
|
||||
"""Hook for extensions
|
||||
|
||||
Read in sudo: a timesheet manager entitled to delete the sheet of
|
||||
another employee is not necessarily entitled to read that employee's
|
||||
time off, and the confirmation wizard has to describe it. Only reached
|
||||
with the ``cancel`` policy; the ``detach`` policy never reads hr.leave.
|
||||
"""
|
||||
return self._sheet_leave_lines().holiday_id.sudo()
|
||||
|
||||
def _sheet_leaves_to_cancel(self):
|
||||
"""Hook for extensions"""
|
||||
return self._sheet_leaves().filtered(
|
||||
lambda leave: leave.state in REFUSABLE_LEAVE_STATES
|
||||
)
|
||||
|
||||
def _sheet_leaves_not_cancellable(self):
|
||||
"""Hook for extensions"""
|
||||
return self._sheet_leaves().filtered(
|
||||
lambda leave: leave.state not in REFUSABLE_LEAVE_STATES
|
||||
)
|
||||
|
||||
def _sheet_leaves_beyond_period(self):
|
||||
"""Hook for extensions"""
|
||||
sheet_lines = self.mapped("timesheet_ids")
|
||||
leaves = self._sheet_leaves_to_cancel()
|
||||
return leaves.filtered(lambda leave: bool(leave.timesheet_ids - sheet_lines))
|
||||
|
||||
def _delete_cascade_blocking(self):
|
||||
"""Hook for extensions"""
|
||||
reasons = []
|
||||
for sheet in self:
|
||||
invoiced = sheet.timesheet_ids.filtered("timesheet_invoice_id")
|
||||
if invoiced:
|
||||
reasons.append(
|
||||
_(
|
||||
"Sheet %(sheet)s contains %(count)s invoiced timesheet"
|
||||
" entries, which can never be deleted.",
|
||||
sheet=sheet.complete_name,
|
||||
count=len(invoiced),
|
||||
)
|
||||
)
|
||||
if sheet.state in ("confirm", "done"):
|
||||
reasons.append(
|
||||
_(
|
||||
"Sheet %(sheet)s is already submitted or approved and"
|
||||
" cannot be deleted.",
|
||||
sheet=sheet.complete_name,
|
||||
)
|
||||
)
|
||||
if sheet._sheet_leave_policy() != "cancel":
|
||||
continue
|
||||
stuck = sheet._sheet_leaves_not_cancellable()
|
||||
if stuck:
|
||||
reasons.append(
|
||||
_(
|
||||
"Sheet %(sheet)s contains entries of time off that"
|
||||
" cannot be cancelled any more: %(leaves)s. Remove"
|
||||
" them from the Time Off application first.",
|
||||
sheet=sheet.complete_name,
|
||||
leaves=", ".join(
|
||||
"%s (%s)" % (leave.display_name, leave.state)
|
||||
for leave in stuck
|
||||
),
|
||||
)
|
||||
)
|
||||
return reasons
|
||||
|
||||
def _delete_cascade_check(self):
|
||||
"""Hook for extensions"""
|
||||
reasons = self._delete_cascade_blocking()
|
||||
if reasons:
|
||||
raise UserError("\n".join(reasons))
|
||||
|
||||
def _delete_cascade_detach_section(self):
|
||||
"""Hook for extensions"""
|
||||
leave_lines = self._sheet_leave_lines()
|
||||
if not leave_lines:
|
||||
return None
|
||||
return {
|
||||
"title": _("Time off"),
|
||||
"lines": [
|
||||
_(
|
||||
"%s time off entries will be detached from the sheet and"
|
||||
" will survive the deletion.",
|
||||
len(leave_lines),
|
||||
),
|
||||
_(
|
||||
"The time off requests themselves are not modified and the"
|
||||
" employee balances do not change."
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
def _delete_cascade_cancel_section(self):
|
||||
"""Hook for extensions"""
|
||||
leaves = self._sheet_leaves_to_cancel()
|
||||
if not self._sheet_leave_lines():
|
||||
return None, 0.0
|
||||
lines = [
|
||||
_(
|
||||
"%(employee)s - %(type)s (%(date_from)s - %(date_to)s):"
|
||||
" %(days)s days",
|
||||
employee=leave.employee_id.name,
|
||||
type=leave.holiday_status_id.name,
|
||||
date_from=format_date(self.env, leave.date_from),
|
||||
date_to=format_date(self.env, leave.date_to),
|
||||
days=leave.number_of_days,
|
||||
)
|
||||
for leave in leaves
|
||||
]
|
||||
leave_days = sum(leaves.mapped("number_of_days"))
|
||||
lines.append(
|
||||
_("%s days will be restored to the employee balances.", leave_days)
|
||||
)
|
||||
beyond = self._sheet_leaves_beyond_period()
|
||||
if beyond:
|
||||
lines.append(
|
||||
_(
|
||||
"Warning: the following time off requests also cover days"
|
||||
" outside the period of the deleted sheets, and they will"
|
||||
" be cancelled in full: %s",
|
||||
", ".join(
|
||||
"%s (%s - %s)"
|
||||
% (
|
||||
leave.employee_id.name,
|
||||
format_date(self.env, leave.date_from),
|
||||
format_date(self.env, leave.date_to),
|
||||
)
|
||||
for leave in beyond
|
||||
),
|
||||
)
|
||||
)
|
||||
return {"title": _("Time off to cancel"), "lines": lines}, leave_days
|
||||
|
||||
def _delete_cascade_leave_sections(self):
|
||||
"""Hook for extensions
|
||||
|
||||
Built per policy rather than per batch: a selection may span companies
|
||||
that answer differently, and the summary has to describe what each
|
||||
sheet is really going to do.
|
||||
"""
|
||||
sections = []
|
||||
cancel_sheets = self._delete_cascade_cancel_sheets()
|
||||
detach_section = (self - cancel_sheets)._delete_cascade_detach_section()
|
||||
if detach_section:
|
||||
sections.append(detach_section)
|
||||
cancel_section, leave_days = cancel_sheets._delete_cascade_cancel_section()
|
||||
if cancel_section:
|
||||
sections.append(cancel_section)
|
||||
return sections, leave_days
|
||||
|
||||
def _delete_cascade_prepare(self):
|
||||
"""Hook for extensions"""
|
||||
lines = self.mapped("timesheet_ids")
|
||||
invoiced = lines.filtered("timesheet_invoice_id")
|
||||
hours = sum(lines.mapped("unit_amount"))
|
||||
sections = [
|
||||
{
|
||||
"title": _("Timesheet sheets to delete"),
|
||||
"lines": [sheet.complete_name for sheet in self],
|
||||
},
|
||||
{
|
||||
"title": _("Timesheet entries to delete"),
|
||||
"lines": [
|
||||
_(
|
||||
"%(count)s entries, %(hours)s hours in total.",
|
||||
count=len(lines),
|
||||
hours=format_duration(hours),
|
||||
)
|
||||
],
|
||||
},
|
||||
]
|
||||
leave_sections, leave_days = self._delete_cascade_leave_sections()
|
||||
sections.extend(leave_sections)
|
||||
return {
|
||||
"sheet_count": len(self),
|
||||
"timesheet_count": len(lines),
|
||||
"timesheet_hours": hours,
|
||||
"invoiced_count": len(invoiced),
|
||||
"leave_count": len(self._sheet_leave_lines()),
|
||||
"leave_days": leave_days,
|
||||
"leave_policy": self._sheet_leave_policy(),
|
||||
"blocking": self._delete_cascade_blocking(),
|
||||
"sections": sections,
|
||||
}
|
||||
|
||||
def _delete_cascade_cancel_leaves(self):
|
||||
"""Hook for extensions"""
|
||||
# sudo() is not enough here, and the extra elevation is deliberate.
|
||||
# sudo() flips env.su but keeps the uid, and hr.leave's
|
||||
# _unlink_if_correct_states gates the deletion on
|
||||
# user_has_groups('hr_holidays.group_hr_holidays_user'), which reads
|
||||
# the uid: without a Time Off officer behind it, deleting a time off
|
||||
# that already started raises. SUPERUSER_ID is the only way to run the
|
||||
# documented refuse/draft/unlink sequence as any employee.
|
||||
# It stays safe because it is confined to hr.leave, it only ever
|
||||
# reaches the requests found inside sheets the user is already allowed
|
||||
# to delete by the record rules of this module, and it is unreachable
|
||||
# unless the company policy is "cancel".
|
||||
leaves = self._sheet_leaves_to_cancel().with_user(SUPERUSER_ID).sudo()
|
||||
if not leaves:
|
||||
return
|
||||
leaves.action_refuse()
|
||||
leaves.action_draft()
|
||||
leaves.unlink()
|
||||
|
||||
def _delete_cascade_apply(self):
|
||||
"""Hook for extensions"""
|
||||
for sheet in self:
|
||||
if sheet._sheet_leave_policy() == "cancel":
|
||||
sheet._delete_cascade_cancel_leaves()
|
||||
# action_refuse() calls _check_missing_global_leave_timesheets(),
|
||||
# which can create public holiday entries that land in this very
|
||||
# sheet. Re-read the set so they are deleted with it instead of
|
||||
# being left behind pointing at a sheet that no longer exists.
|
||||
sheet.invalidate_recordset(["timesheet_ids"])
|
||||
sheet.timesheet_ids.exists().unlink()
|
||||
continue
|
||||
lines = sheet.timesheet_ids.exists()
|
||||
leave_lines = lines.filtered("holiday_id")
|
||||
if leave_lines:
|
||||
leave_lines.write({"sheet_id": False})
|
||||
other_lines = lines - leave_lines
|
||||
if other_lines:
|
||||
other_lines.unlink()
|
||||
|
||||
def unlink(self):
|
||||
if not self._in_delete_cascade():
|
||||
raise UserError(
|
||||
_(
|
||||
"Deleting a timesheet sheet also deletes its timesheet"
|
||||
" entries and cannot be undone. Use the Delete Sheet"
|
||||
" button on the sheet to review what will be deleted and"
|
||||
" confirm it."
|
||||
)
|
||||
)
|
||||
self._delete_cascade_check()
|
||||
self._delete_cascade_apply()
|
||||
return super().unlink()
|
||||
|
||||
def action_delete_sheet(self):
|
||||
return {
|
||||
"type": "ir.actions.act_window",
|
||||
"name": _("Delete Timesheet Sheet"),
|
||||
"res_model": "hr_timesheet.sheet.delete.wizard",
|
||||
"view_mode": "form",
|
||||
"target": "new",
|
||||
"context": dict(
|
||||
self.env.context,
|
||||
active_model="hr_timesheet.sheet",
|
||||
active_ids=self.ids,
|
||||
active_id=self.ids[0] if self.ids else False,
|
||||
),
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue