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 models

View file

@ -0,0 +1,17 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
{
"name": "HR Timesheet Sheet - Time Off",
"version": "16.0.1.0.0",
"category": "Human Resources",
"summary": "Make time off entries inert but visible inside timesheet sheets",
"author": "Criptomart, Odoo Community Association (OCA)",
"website": "https://github.com/OCA/timesheet",
"license": "AGPL-3",
"depends": ["hr_timesheet_sheet", "project_timesheet_holidays"],
"data": [],
"installable": True,
"auto_install": True,
"development_status": "Beta",
}

View file

@ -0,0 +1,41 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * hr_timesheet_sheet_holidays
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 16.0\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: \n"
"Last-Translator: \n"
"Language-Team: \n"
"Language: es\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#. module: hr_timesheet_sheet_holidays
#: model:ir.model,name:hr_timesheet_sheet_holidays.model_account_analytic_line
msgid "Analytic Line"
msgstr "Línea analítica"
#. module: hr_timesheet_sheet_holidays
#. odoo-python
#: code:addons/hr_timesheet_sheet_holidays/models/account_analytic_line.py:0
#, python-format
msgid ""
"Time off hours are managed from the Time Off application and cannot be "
"changed from a timesheet sheet.\n"
"Sheet: %(sheet)s\n"
"Day: %(day)s"
msgstr ""
"Las horas de una ausencia se gestionan desde la aplicación Ausencias y no se "
"pueden modificar desde una hoja de servicio.\n"
"Hoja: %(sheet)s\n"
"Día: %(day)s"
#. module: hr_timesheet_sheet_holidays
#: model:ir.model,name:hr_timesheet_sheet_holidays.model_hr_timesheet_sheet
msgid "Timesheet Sheet"
msgstr "Hoja de servicio"

View file

@ -0,0 +1,35 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * hr_timesheet_sheet_holidays
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 16.0\n"
"Report-Msgid-Bugs-To: \n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: hr_timesheet_sheet_holidays
#: model:ir.model,name:hr_timesheet_sheet_holidays.model_account_analytic_line
msgid "Analytic Line"
msgstr ""
#. module: hr_timesheet_sheet_holidays
#. odoo-python
#: code:addons/hr_timesheet_sheet_holidays/models/account_analytic_line.py:0
#, python-format
msgid ""
"Time off hours are managed from the Time Off application and cannot be "
"changed from a timesheet sheet.\n"
"Sheet: %(sheet)s\n"
"Day: %(day)s"
msgstr ""
#. module: hr_timesheet_sheet_holidays
#: model:ir.model,name:hr_timesheet_sheet_holidays.model_hr_timesheet_sheet
msgid "Timesheet Sheet"
msgstr ""

View file

@ -0,0 +1,2 @@
from . import account_analytic_line
from . import hr_timesheet_sheet

View file

@ -0,0 +1,74 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import _, models
from odoo.exceptions import UserError
from odoo.tools.misc import format_date
# 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. These
# sentinels are compared by identity and cannot be produced by a deserializer,
# so they only ever reach the consumer from inside this process.
_SHEET_LINK_WRITE = object()
_SHEET_RECOMPUTE = object()
class AccountAnalyticLine(models.Model):
_inherit = "account.analytic.line"
def _sheet_neutral_fields(self):
"""Hook for extensions"""
return {"sheet_id"}
def _sheet_link_write_token(self):
"""Hook for extensions"""
return _SHEET_LINK_WRITE
def _sheet_recompute_token(self):
"""Hook for extensions"""
return _SHEET_RECOMPUTE
def _in_sheet_link_write(self):
return (
self.env.context.get("sheet_link_write") is self._sheet_link_write_token()
)
def _detach_leave_lines_on_unlink(self):
"""Hook for extensions"""
return self.env.context.get("sheet_recompute") is self._sheet_recompute_token()
def _check_can_update_timesheet(self):
if self._in_sheet_link_write():
return True
if not self.env.su and self.filtered("holiday_id"):
return False
return super()._check_can_update_timesheet()
def write(self, vals):
if vals and self.filtered("holiday_id"):
if set(vals) <= self._sheet_neutral_fields():
linking = self.with_context(
sheet_link_write=self._sheet_link_write_token()
)
return super(AccountAnalyticLine, linking).write(vals)
if not self._check_can_update_timesheet():
line = self.filtered("holiday_id")[:1]
raise UserError(
_(
"Time off hours are managed from the Time Off application"
" and cannot be changed from a timesheet sheet.\n"
"Sheet: %(sheet)s\n"
"Day: %(day)s",
sheet=line.sheet_id.complete_name or "-",
day=format_date(self.env, line.date),
)
)
return super().write(vals)
def unlink(self):
leave_lines = self.filtered("holiday_id")
if leave_lines and leave_lines._detach_leave_lines_on_unlink():
leave_lines.write({"sheet_id": False})
return super(AccountAnalyticLine, self - leave_lines).unlink()
return super().unlink()

View file

@ -0,0 +1,21 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import api, models
class Sheet(models.Model):
_inherit = "hr_timesheet.sheet"
def _with_sheet_recompute(self):
"""Hook for extensions"""
token = self.env["account.analytic.line"]._sheet_recompute_token()
return self.with_context(sheet_recompute=token)
@api.model_create_multi
def create(self, vals_list):
sheets = super(Sheet, self._with_sheet_recompute()).create(vals_list)
return sheets.with_env(self.env)
def write(self, vals):
return super(Sheet, self._with_sheet_recompute()).write(vals)

View file

@ -0,0 +1,3 @@
* Criptomart
* Luis Díez <luis@criptomart.net>

View file

@ -0,0 +1,24 @@
``hr_timesheet_sheet`` works on every analytic line of a period at once, while
``project_timesheet_holidays`` protects the analytic lines it generates for validated
time off against any modification or deletion. Together they make any timesheet sheet
that spans a day with validated time off unmanageable: opening, recomputing, creating or
saving such a sheet raises a ``UserError`` and the employee cannot record their hours.
This bridge module makes time off lines inert with respect to the bulk operations of the
sheet while keeping them visible inside it, so that the week is shown complete with its
time off hours included. Two different code paths are involved and both are handled: the
bulk ``write`` that links or unlinks ``sheet_id``, and the ``Command.DELETE`` the web
client emits for the lines that leave the set cached by the onchange, which ends up in a
real ``unlink()``. In the second case the time off line is detached from the sheet
(``sheet_id = False``) instead of being deleted; the time off request and the employee
balance are never touched.
The protection itself is not relaxed: the hours, date, project, task, employee and
description of a time off line still cannot be changed from a timesheet sheet, the line
still cannot be deleted by hand and new timesheets still cannot be created on a time off
task. Only a write that touches nothing but the neutral link fields is let through.
The module also closes a pre-existing hole in ``hr_timesheet_sheet``, whose
``_check_can_update_timesheet()`` returned ``True`` for any recordset without
``sheet_id`` and therefore silently disabled the core protection for every time off line
not linked to a sheet.

View file

@ -0,0 +1,5 @@
* The 2D matrix widget cannot make a single row read-only, so the time off row looks
editable even though any attempt to change it is rejected. Making the cell read-only in
the client would require an extension of ``web_widget_x2many_2d_matrix``.
* Lines generated from public holidays (``global_leave_id``) are not covered: they are
not protected by ``project_timesheet_holidays`` and therefore never blocked the sheet.

View file

@ -0,0 +1,39 @@
Nothing to configure. Once installed, a timesheet sheet covering a week with validated
time off can be opened, recomputed, created and saved by the employee, and the time off
lines are shown in the *Summary* matrix and in the *Details* tab with their project, task
and hours, adding up to the total of the sheet.
Time off hours are still read-only from the sheet. Trying to change them raises an error
pointing the user to the *Time Off* application, naming the sheet and the day concerned.
To change them, cancel or modify the time off request in the *Time Off* application.
A time off line may end up detached from every sheet (``sheet_id = False``) when the
period or the employee of a sheet is changed in the form. Nothing is lost: the line, the
time off request and the balance are untouched, and the line is linked again as soon as a
sheet covering that day is recomputed.
For developers, the module publishes two hooks on ``account.analytic.line``:
* ``_sheet_neutral_fields()`` returns the set of fields whose write is considered a pure
sheet link operation (``{"sheet_id"}`` by default). Widening it requires reviewing the
protection of time off hours.
* ``_detach_leave_lines_on_unlink()`` returns whether an ``unlink()`` of time off lines
must detach them instead of deleting them.
Both behaviours are driven by the internal context flags ``sheet_link_write`` and
``sheet_recompute``. The context of an RPC call is controlled by the client, so those
flags do **not** carry a boolean: they carry a module-level sentinel object, obtained
from ``_sheet_link_write_token()`` / ``_sheet_recompute_token()`` and compared by
identity. A JSON deserializer cannot produce such an object, so the flags cannot be
forged from outside the process. Never replace a token with a value that survives
serialization, and never set either flag from a view, an action or a user context.
Three tests are mandatory regression tests for any change to those flags:
* ``test_no_context_leak_after_sheet_operation`` — the flag does not escape: right after
a sheet operation, in the same transaction, a direct write or unlink on a time off line
is blocked again.
* ``test_forged_sheet_link_write_flag_does_not_unlock_write`` and
``test_forged_sheet_recompute_flag_does_not_unlock_unlink`` — the flag cannot be
injected: a boolean sent through ``odoo.api.call_kw``, the entry point the web client
uses, unlocks nothing.

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