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 @@
from . import test_hr_timesheet_sheet_holidays

View file

@ -0,0 +1,509 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
import json
from datetime import date, datetime
from odoo.api import call_kw
from odoo.exceptions import UserError
from odoo.tests import tagged
from odoo.tests.common import Form, TransactionCase
CORE_WRITE_ERROR = "You cannot modify timesheets that are linked to time off requests"
@tagged("post_install", "-at_install")
class TestHrTimesheetSheetHolidays(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.env = cls.env(
context=dict(
cls.env.context,
tracking_disable=True,
no_reset_password=True,
mail_create_nolog=True,
mail_notrack=True,
)
)
cls.week_start = date(2026, 9, 7)
cls.week_end = date(2026, 9, 13)
cls.previous_week_start = date(2026, 8, 31)
cls.previous_week_end = date(2026, 9, 6)
cls.leave_start = datetime(2026, 9, 7, 6, 0, 0)
cls.leave_end = datetime(2026, 9, 11, 18, 0, 0)
cls.company = cls.env["res.company"].create({"name": "Timesheet Holidays Co"})
cls.env.user.write({"company_ids": [(4, cls.company.id)]})
cls.company.resource_calendar_id.tz = "UTC"
cls.internal_project = cls.company.internal_project_id
cls.timeoff_task = cls.company.leave_timesheet_task_id
cls.user = cls._new_user("tsh_holidays_employee")
cls.user_other = cls._new_user("tsh_holidays_other")
cls.user_third = cls._new_user("tsh_holidays_third")
# Only a timesheet manager can open a sheet on behalf of another
# employee, so this is the role that reaches the "change the employee"
# sequence of R2. It is not a superuser: env.su stays False and the
# time off protection is fully active for it.
cls.user_manager = cls._new_user(
"tsh_holidays_manager", ("hr_timesheet.group_timesheet_manager",)
)
cls.employee = cls._new_employee("Sheet Employee", cls.user)
cls.employee_other = cls._new_employee("Other Employee", cls.user_other)
cls.employee_third = cls._new_employee("Third Employee", cls.user_third)
cls.project = cls.env["project.project"].create(
{
"name": "Timesheet Holidays Project",
"company_id": cls.company.id,
"allow_timesheets": True,
"is_timesheet_task_required": False,
}
)
cls.leave_type = cls.env["hr.leave.type"].create(
{
"name": "Paid Time Off (test)",
"company_id": cls.company.id,
"requires_allocation": "yes",
"allocation_validation_type": "no",
"leave_validation_type": "no_validation",
"timesheet_generate": True,
"timesheet_project_id": cls.internal_project.id,
"timesheet_task_id": cls.timeoff_task.id,
}
)
cls._allocate(cls.employee)
cls._allocate(cls.employee_third)
cls.leave = cls._request_leave(cls.employee, cls.leave_start, cls.leave_end)
cls.leave_third = cls._request_leave(
cls.employee_third, cls.leave_start, cls.leave_end
)
cls.leave_lines = cls.leave.sudo().timesheet_ids
cls.leave_lines_third = cls.leave_third.sudo().timesheet_ids
cls.sheet_third = cls.env["hr_timesheet.sheet"].create(
{
"employee_id": cls.employee_third.id,
"company_id": cls.company.id,
"date_start": cls.week_start,
"date_end": cls.week_end,
}
)
cls.sheet_third._compute_timesheet_ids()
@classmethod
def _new_user(cls, login, extra_groups=()):
return (
cls.env["res.users"]
.with_context(no_reset_password=True, default_role_line_ids=[])
.create(
{
"name": login,
"login": login,
"email": "%s@example.org" % login,
"company_id": cls.company.id,
"company_ids": [(6, 0, [cls.company.id])],
"groups_id": [
(
6,
0,
[
cls.env.ref("hr_timesheet.group_hr_timesheet_user").id,
cls.env.ref("project.group_project_user").id,
# Third party add-ons read project_id.stage_id
# when a timesheet line is written, and that
# field is protected by this group.
cls.env.ref("project.group_project_stages").id,
]
+ [cls.env.ref(xmlid).id for xmlid in extra_groups],
)
],
}
)
)
@classmethod
def _new_employee(cls, name, user):
return cls.env["hr.employee"].create(
{"name": name, "user_id": user.id, "company_id": cls.company.id}
)
@classmethod
def _allocate(cls, employee):
allocation = cls.env["hr.leave.allocation"].create(
{
"name": "Allocation for %s" % employee.name,
"holiday_status_id": cls.leave_type.id,
"employee_id": employee.id,
"number_of_days": 25,
"date_from": date(2026, 1, 1),
}
)
if allocation.state != "validate":
allocation.action_validate()
return allocation
@classmethod
def _request_leave(cls, employee, date_from, date_to):
return cls.env["hr.leave"].create(
{
"name": "Time off for %s" % employee.name,
"holiday_status_id": cls.leave_type.id,
"employee_id": employee.id,
"date_from": date_from,
"date_to": date_to,
"number_of_days": 5,
}
)
# Helpers
def _sheet_form(self, user=None):
return Form(self.env["hr_timesheet.sheet"].with_user(user or self.user))
def _leaves_taken(self, employee):
leave_type = self.leave_type.with_context(employee_id=employee.id)
leave_type.invalidate_recordset()
return leave_type.leaves_taken
def _assert_leave_lines_survived(self, lines, leaves_taken_before):
self.assertEqual(lines.exists(), lines)
self.assertFalse(lines.mapped("sheet_id"))
self.assertEqual(self.leave.state, "validate")
self.assertEqual(self._leaves_taken(self.employee), leaves_taken_before)
def _assert_leave_line_is_protected(self, line, user):
line = line.with_user(user)
with self.assertRaises(UserError):
line.write({"unit_amount": 3.0})
self.env.invalidate_all()
with self.assertRaises(UserError):
line.unlink()
self.env.invalidate_all()
self.assertFalse(self.env.context.get("sheet_link_write"))
self.assertFalse(self.env.context.get("sheet_recompute"))
# Fixture sanity
def test_fixture_generated_leave_lines(self):
self.assertEqual(len(self.leave_lines), 5)
self.assertEqual(self.leave.state, "validate")
self.assertEqual(self.leave_lines.mapped("task_id"), self.timeoff_task)
# Published hooks
def test_sheet_neutral_fields_default(self):
lines = self.env["account.analytic.line"]
self.assertEqual(lines._sheet_neutral_fields(), {"sheet_id"})
def test_detach_leave_lines_on_unlink_hook(self):
lines = self.env["account.analytic.line"]
self.assertFalse(lines._detach_leave_lines_on_unlink())
recomposing = lines.with_context(
sheet_recompute=lines._sheet_recompute_token()
)
self.assertTrue(recomposing._detach_leave_lines_on_unlink())
def test_context_tokens_are_not_serializable(self):
lines = self.env["account.analytic.line"]
for token in (lines._sheet_link_write_token(), lines._sheet_recompute_token()):
with self.assertRaises(TypeError):
json.dumps(token)
# RF-01
def test_sheet_recompute_with_leave_lines(self):
sheet = (
self.env["hr_timesheet.sheet"]
.with_user(self.user)
.create(
{
"employee_id": self.employee.id,
"company_id": self.company.id,
"date_start": self.week_start,
"date_end": self.week_end,
}
)
)
sheet._compute_timesheet_ids()
self.assertTrue(sheet.timesheet_ids.filtered("holiday_id"))
self.assertEqual(
sheet.timesheet_ids.filtered("holiday_id"), self.leave_lines
)
self.assertEqual(self.leave_lines.mapped("sheet_id"), sheet.sudo())
sheet.write({"timesheet_ids": [(6, 0, sheet.timesheet_ids.ids)]})
self.assertTrue(sheet.timesheet_ids.filtered("holiday_id"))
def test_form_create_over_leave_week(self):
form = self._sheet_form()
form.employee_id = self.employee
form.date_start = self.week_start
form.date_end = self.week_end
sheet = form.save()
self.assertEqual(len(sheet.timesheet_ids.filtered("holiday_id")), 5)
self.assertEqual(self.leave_lines.mapped("sheet_id"), sheet.sudo())
# RF-02
def test_form_create_moving_period_keeps_leave_lines(self):
taken = self._leaves_taken(self.employee)
form = self._sheet_form()
form.employee_id = self.employee
form.date_start = self.week_start
form.date_end = self.week_end
form.date_start = self.previous_week_start
form.date_end = self.previous_week_end
sheet = form.save()
self.assertFalse(sheet.timesheet_ids.filtered("holiday_id"))
self._assert_leave_lines_survived(self.leave_lines, taken)
def test_form_create_changing_employee_keeps_leave_lines(self):
taken = self._leaves_taken(self.employee)
self.assertFalse(self.env(user=self.user_manager).su)
form = self._sheet_form(self.user_manager)
form.employee_id = self.employee
form.date_start = self.week_start
form.date_end = self.week_end
form.employee_id = self.employee_other
sheet = form.save()
self.assertEqual(sheet.employee_id, self.employee_other)
self.assertFalse(sheet.timesheet_ids.filtered("holiday_id"))
self._assert_leave_lines_survived(self.leave_lines, taken)
def test_form_create_shrinking_period_keeps_leave_lines(self):
taken = self._leaves_taken(self.employee)
kept_day = date(2026, 9, 8)
form = self._sheet_form()
form.employee_id = self.employee
form.date_start = self.week_start
form.date_end = self.week_end
form.date_end = kept_day
sheet = form.save()
dropped = self.leave_lines.filtered(lambda line: line.date > kept_day)
self.assertTrue(dropped)
self.assertEqual(dropped.exists(), dropped)
self.assertFalse(dropped.mapped("sheet_id"))
self.assertEqual(
sheet.timesheet_ids.filtered("holiday_id"),
(self.leave_lines - dropped),
)
self.assertEqual(self.leave.state, "validate")
self.assertEqual(self._leaves_taken(self.employee), taken)
def test_unlink_with_sheet_recompute_detaches_leave_lines(self):
sheet = self._create_linked_sheet()
lines = self.leave_lines
lines.with_user(self.user).with_context(
sheet_recompute=lines._sheet_recompute_token()
).unlink()
self.assertEqual(lines.exists(), lines)
self.assertFalse(lines.mapped("sheet_id"))
self.assertFalse(sheet.timesheet_ids.filtered("holiday_id"))
def test_unlink_with_sheet_recompute_deletes_regular_lines(self):
sheet = self._create_linked_sheet()
regular = self._create_regular_line()
sheet._compute_timesheet_ids()
mixed = (self.leave_lines | regular.sudo()).with_user(self.user)
mixed.with_context(sheet_recompute=mixed._sheet_recompute_token()).unlink()
self.assertFalse(regular.exists())
self.assertEqual(self.leave_lines.exists(), self.leave_lines)
# RF-03
def test_leave_line_hours_are_protected(self):
self._create_linked_sheet()
line = self.leave_lines[0]
self.assertTrue(line.sheet_id)
with self.assertRaises(UserError):
line.with_user(self.user).write({"unit_amount": 99.0})
self.env.invalidate_all()
self.assertNotEqual(line.unit_amount, 99.0)
def test_leave_line_without_sheet_hours_are_protected(self):
line = self.leave_lines[0]
self.assertFalse(line.sheet_id)
self.assertFalse(line.with_user(self.user)._check_can_update_timesheet())
with self.assertRaises(UserError):
line.with_user(self.user).write({"unit_amount": 99.0})
self.env.invalidate_all()
self.assertNotEqual(line.unit_amount, 99.0)
def test_leave_line_hours_protected_inside_sheet_operation(self):
line = self.leave_lines[0].with_user(self.user)
with self.assertRaises(UserError):
line.with_context(
sheet_recompute=line._sheet_recompute_token()
).write({"unit_amount": 99.0})
self.env.invalidate_all()
self.assertNotEqual(self.leave_lines[0].unit_amount, 99.0)
def test_leave_line_mixed_write_is_protected(self):
sheet = self._create_linked_sheet()
line = self.leave_lines[0].with_user(self.user)
with self.assertRaises(UserError):
line.write({"sheet_id": sheet.id, "unit_amount": 99.0})
self.env.invalidate_all()
self.assertNotEqual(self.leave_lines[0].unit_amount, 99.0)
def test_leave_line_other_fields_are_protected(self):
line = self.leave_lines[0].with_user(self.user)
for vals in (
{"date": date(2026, 9, 12)},
{"name": "hacked"},
{"project_id": self.project.id},
{"employee_id": self.employee_other.id},
):
self.env.invalidate_all()
with self.assertRaises(UserError):
line.write(vals)
def test_leave_line_cannot_be_deleted(self):
self._create_linked_sheet()
line = self.leave_lines[0]
with self.assertRaises(UserError):
line.with_user(self.user).unlink()
self.env.invalidate_all()
self.assertTrue(line.exists())
def test_cannot_create_timesheet_on_timeoff_task(self):
with self.assertRaises(UserError):
self.env["account.analytic.line"].with_user(self.user).create(
{
"name": "sneaky",
"project_id": self.internal_project.id,
"task_id": self.timeoff_task.id,
"employee_id": self.employee.id,
"date": self.week_start,
"unit_amount": 4.0,
}
)
# RF-04
def test_leave_line_visible_in_matrix(self):
sheet = self._create_linked_sheet()
self._create_regular_line()
sheet._compute_timesheet_ids()
sheet._compute_line_ids()
leave_rows = sheet.line_ids.filtered(
lambda line: line.project_id == self.internal_project
)
self.assertTrue(leave_rows)
self.assertTrue(sum(leave_rows.mapped("unit_amount")) > 0)
self.assertEqual(
sum(sheet.timesheet_ids.mapped("unit_amount")), sheet.total_time
)
self.assertTrue(sheet.total_time >= sum(self.leave_lines.mapped("unit_amount")))
# RF-05
def test_leave_line_error_message(self):
sheet = self._create_linked_sheet()
line = self.leave_lines[0]
with self.assertRaises(UserError) as catcher:
line.with_user(self.user).write({"unit_amount": 99.0})
message = str(catcher.exception)
self.assertNotIn(CORE_WRITE_ERROR, message)
self.assertIn("Time Off application", message)
self.assertIn(sheet.complete_name, message)
self.assertIn("Day:", message)
# RF-06
def test_forged_sheet_link_write_flag_does_not_unlock_write(self):
line = self.leave_lines[0]
model = self.env["account.analytic.line"].with_user(self.user)
with self.assertRaises(UserError):
call_kw(
model,
"write",
[[line.id], {"unit_amount": 99.0}],
{"context": {"sheet_link_write": True}},
)
self.env.invalidate_all()
self.assertNotEqual(self.leave_lines[0].unit_amount, 99.0)
self.assertFalse(
model.with_context(sheet_link_write=True)._in_sheet_link_write()
)
def test_forged_sheet_recompute_flag_does_not_unlock_unlink(self):
sheet = self._create_linked_sheet()
line = self.leave_lines[0]
model = self.env["account.analytic.line"].with_user(self.user)
with self.assertRaises(UserError):
call_kw(
model,
"unlink",
[[line.id]],
{"context": {"sheet_recompute": True}},
)
self.env.invalidate_all()
self.assertTrue(line.exists())
self.assertEqual(line.sheet_id, sheet.sudo())
self.assertFalse(
model.with_context(
sheet_recompute=True
)._detach_leave_lines_on_unlink()
)
def test_no_context_leak_after_sheet_operation(self):
form = self._sheet_form()
form.employee_id = self.employee
form.date_start = self.week_start
form.date_end = self.week_end
form.date_start = self.previous_week_start
form.date_end = self.previous_week_end
sheet = form.save()
self.assertFalse(sheet.env.context.get("sheet_recompute"))
self.assertFalse(sheet.env.context.get("sheet_link_write"))
self._assert_leave_line_is_protected(self.leave_lines[0], self.user)
self._assert_leave_line_is_protected(
self.leave_lines_third[0], self.user_third
)
self.sheet_third.with_user(self.user_third).write(
{"timesheet_ids": [(6, 0, self.sheet_third.timesheet_ids.ids)]}
)
self.assertFalse(self.env.context.get("sheet_recompute"))
self.assertFalse(self.env.context.get("sheet_link_write"))
self._assert_leave_line_is_protected(self.leave_lines[0], self.user)
self._assert_leave_line_is_protected(
self.leave_lines_third[0], self.user_third
)
# Internal helpers
def _create_linked_sheet(self):
sheet = (
self.env["hr_timesheet.sheet"]
.with_user(self.user)
.create(
{
"employee_id": self.employee.id,
"company_id": self.company.id,
"date_start": self.week_start,
"date_end": self.week_end,
}
)
)
sheet._compute_timesheet_ids()
return sheet
def _create_regular_line(self):
return (
self.env["account.analytic.line"]
.with_user(self.user)
.create(
{
"name": "Regular work",
"project_id": self.project.id,
"employee_id": self.employee.id,
"date": self.week_start,
"unit_amount": 4.0,
}
)
)