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
1
hr_timesheet_sheet_holidays/tests/__init__.py
Normal file
1
hr_timesheet_sheet_holidays/tests/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import test_hr_timesheet_sheet_holidays
|
||||
|
|
@ -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,
|
||||
}
|
||||
)
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue