add hr_timesheet_sheet_no_review, hr_timesheet_sheet_holidays, hr_timesheet_sheet_delete_cascade

This commit is contained in:
Luis 2026-09-22 10:58:36 +02:00
parent b07082f70d
commit 8b0d0bb6cc
53 changed files with 3953 additions and 0 deletions

View file

@ -0,0 +1,4 @@
from . import res_company
from . import res_config_settings
from . import account_analytic_line
from . import hr_timesheet_sheet

View file

@ -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()

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

View 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.",
)

View file

@ -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,
)