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/__init__.py
Normal file
1
hr_timesheet_sheet_holidays/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import models
|
||||
17
hr_timesheet_sheet_holidays/__manifest__.py
Normal file
17
hr_timesheet_sheet_holidays/__manifest__.py
Normal 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",
|
||||
}
|
||||
41
hr_timesheet_sheet_holidays/i18n/es.po
Normal file
41
hr_timesheet_sheet_holidays/i18n/es.po
Normal 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"
|
||||
|
|
@ -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 ""
|
||||
2
hr_timesheet_sheet_holidays/models/__init__.py
Normal file
2
hr_timesheet_sheet_holidays/models/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from . import account_analytic_line
|
||||
from . import hr_timesheet_sheet
|
||||
74
hr_timesheet_sheet_holidays/models/account_analytic_line.py
Normal file
74
hr_timesheet_sheet_holidays/models/account_analytic_line.py
Normal 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()
|
||||
21
hr_timesheet_sheet_holidays/models/hr_timesheet_sheet.py
Normal file
21
hr_timesheet_sheet_holidays/models/hr_timesheet_sheet.py
Normal 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)
|
||||
3
hr_timesheet_sheet_holidays/readme/CONTRIBUTORS.rst
Normal file
3
hr_timesheet_sheet_holidays/readme/CONTRIBUTORS.rst
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
* Criptomart
|
||||
|
||||
* Luis Díez <luis@criptomart.net>
|
||||
24
hr_timesheet_sheet_holidays/readme/DESCRIPTION.rst
Normal file
24
hr_timesheet_sheet_holidays/readme/DESCRIPTION.rst
Normal 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.
|
||||
5
hr_timesheet_sheet_holidays/readme/ROADMAP.rst
Normal file
5
hr_timesheet_sheet_holidays/readme/ROADMAP.rst
Normal 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.
|
||||
39
hr_timesheet_sheet_holidays/readme/USAGE.rst
Normal file
39
hr_timesheet_sheet_holidays/readme/USAGE.rst
Normal 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.
|
||||
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