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_delete_cascade/tests/__init__.py
Normal file
1
hr_timesheet_sheet_delete_cascade/tests/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import test_hr_timesheet_sheet_delete_cascade
|
||||
|
|
@ -0,0 +1,744 @@
|
|||
# Copyright 2026 Criptomart
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
|
||||
|
||||
import json
|
||||
from datetime import date, datetime
|
||||
|
||||
from odoo import SUPERUSER_ID
|
||||
from odoo.api import call_kw
|
||||
from odoo.exceptions import AccessError, UserError
|
||||
from odoo.tests import tagged
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
WEEK_START = date(2026, 10, 5)
|
||||
WEEK_END = date(2026, 10, 11)
|
||||
|
||||
|
||||
@tagged("post_install", "-at_install")
|
||||
class TestHrTimesheetSheetDeleteCascade(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.company = cls.env["res.company"].create({"name": "Delete Cascade 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("tsdc_employee")
|
||||
cls.user_other = cls._new_user("tsdc_other")
|
||||
cls.user_span = cls._new_user("tsdc_span")
|
||||
cls.user_manager = cls._new_user(
|
||||
"tsdc_manager", group="hr_timesheet.group_timesheet_manager"
|
||||
)
|
||||
cls.user_internal = cls._new_internal_user("tsdc_internal")
|
||||
cls.employee = cls._new_employee("Cascade Employee", cls.user)
|
||||
cls.employee_other = cls._new_employee("Other Employee", cls.user_other)
|
||||
cls.employee_span = cls._new_employee("Span Employee", cls.user_span)
|
||||
cls._new_employee("Cascade Manager", cls.user_manager)
|
||||
|
||||
cls.project = cls.env["project.project"].create(
|
||||
{
|
||||
"name": "Delete Cascade 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 (cascade 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_span)
|
||||
|
||||
cls.leave = cls._request_leave(
|
||||
cls.employee,
|
||||
datetime(2026, 10, 6, 6, 0, 0),
|
||||
datetime(2026, 10, 8, 18, 0, 0),
|
||||
)
|
||||
cls.leave_lines = cls.leave.sudo().timesheet_ids
|
||||
cls.leave_span = cls._request_leave(
|
||||
cls.employee_span,
|
||||
datetime(2026, 10, 8, 6, 0, 0),
|
||||
datetime(2026, 10, 13, 18, 0, 0),
|
||||
)
|
||||
cls.leave_span_lines = cls.leave_span.sudo().timesheet_ids
|
||||
|
||||
cls.sheet = cls._new_sheet(cls.employee)
|
||||
cls.regular_line = cls._new_regular_line(cls.employee, 4.0)
|
||||
cls.sheet._compute_timesheet_ids()
|
||||
cls.sheet_other = cls._new_sheet(cls.employee_other)
|
||||
|
||||
# Fixture helpers
|
||||
|
||||
@classmethod
|
||||
def _new_user(cls, login, group="hr_timesheet.group_hr_timesheet_user"):
|
||||
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(group).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,
|
||||
],
|
||||
)
|
||||
],
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _new_internal_user(cls, login):
|
||||
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("base.group_user").id])],
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
@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,
|
||||
}
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _new_sheet(cls, employee):
|
||||
return cls.env["hr_timesheet.sheet"].create(
|
||||
{
|
||||
"employee_id": employee.id,
|
||||
"company_id": cls.company.id,
|
||||
"date_start": WEEK_START,
|
||||
"date_end": WEEK_END,
|
||||
}
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _new_regular_line(cls, employee, hours):
|
||||
return cls.env["account.analytic.line"].create(
|
||||
{
|
||||
"name": "Regular work",
|
||||
"project_id": cls.project.id,
|
||||
"employee_id": employee.id,
|
||||
"date": WEEK_START,
|
||||
"unit_amount": hours,
|
||||
}
|
||||
)
|
||||
|
||||
# Test helpers
|
||||
|
||||
def _set_policy(self, policy):
|
||||
self.company.timesheet_sheet_delete_leave_policy = policy
|
||||
|
||||
def _recompute(self, records):
|
||||
token = self.env["account.analytic.line"]._sheet_recompute_token()
|
||||
return records.with_context(sheet_recompute=token)
|
||||
|
||||
def _cascade(self, records):
|
||||
token = self.env["account.analytic.line"]._sheet_delete_cascade_token()
|
||||
return records.with_context(sheet_delete_cascade=token)
|
||||
|
||||
def _wizard(self, sheets, user=None):
|
||||
return (
|
||||
self.env["hr_timesheet.sheet.delete.wizard"]
|
||||
.with_user(user or self.user)
|
||||
.with_context(
|
||||
active_model="hr_timesheet.sheet", active_ids=sheets.ids
|
||||
)
|
||||
.create({})
|
||||
)
|
||||
|
||||
def _leave_balance(self, employee):
|
||||
leave_type = self.leave_type.with_context(employee_id=employee.id)
|
||||
leave_type.invalidate_recordset()
|
||||
return leave_type.leaves_taken, leave_type.virtual_remaining_leaves
|
||||
|
||||
def _span_sheet(self):
|
||||
sheet = self._new_sheet(self.employee_span)
|
||||
sheet._compute_timesheet_ids()
|
||||
return sheet
|
||||
|
||||
def _second_company_sheet_with_leave(self, policy):
|
||||
company = self.env["res.company"].create({"name": "Cascade Co %s" % policy})
|
||||
company.resource_calendar_id.tz = "UTC"
|
||||
company.timesheet_sheet_delete_leave_policy = policy
|
||||
user = (
|
||||
self.env["res.users"]
|
||||
.with_context(no_reset_password=True, default_role_line_ids=[])
|
||||
.create(
|
||||
{
|
||||
"name": "tsdc_%s" % policy,
|
||||
"login": "tsdc_%s" % policy,
|
||||
"email": "tsdc_%s@example.org" % policy,
|
||||
"company_id": company.id,
|
||||
"company_ids": [(6, 0, [company.id])],
|
||||
"groups_id": [
|
||||
(
|
||||
6,
|
||||
0,
|
||||
[
|
||||
self.env.ref(
|
||||
"hr_timesheet.group_hr_timesheet_user"
|
||||
).id,
|
||||
self.env.ref("project.group_project_user").id,
|
||||
self.env.ref("project.group_project_stages").id,
|
||||
],
|
||||
)
|
||||
],
|
||||
}
|
||||
)
|
||||
)
|
||||
employee = self.env["hr.employee"].create(
|
||||
{
|
||||
"name": "Employee %s" % policy,
|
||||
"user_id": user.id,
|
||||
"company_id": company.id,
|
||||
}
|
||||
)
|
||||
leave_type = self.env["hr.leave.type"].create(
|
||||
{
|
||||
"name": "Paid Time Off (%s)" % policy,
|
||||
"company_id": company.id,
|
||||
"requires_allocation": "yes",
|
||||
"allocation_validation_type": "no",
|
||||
"leave_validation_type": "no_validation",
|
||||
"timesheet_generate": True,
|
||||
"timesheet_project_id": company.internal_project_id.id,
|
||||
"timesheet_task_id": company.leave_timesheet_task_id.id,
|
||||
}
|
||||
)
|
||||
allocation = self.env["hr.leave.allocation"].create(
|
||||
{
|
||||
"name": "Allocation %s" % policy,
|
||||
"holiday_status_id": leave_type.id,
|
||||
"employee_id": employee.id,
|
||||
"number_of_days": 25,
|
||||
"date_from": date(2026, 1, 1),
|
||||
}
|
||||
)
|
||||
if allocation.state != "validate":
|
||||
allocation.action_validate()
|
||||
leave = self.env["hr.leave"].create(
|
||||
{
|
||||
"name": "Time off %s" % policy,
|
||||
"holiday_status_id": leave_type.id,
|
||||
"employee_id": employee.id,
|
||||
"date_from": datetime(2026, 10, 6, 6, 0, 0),
|
||||
"date_to": datetime(2026, 10, 7, 18, 0, 0),
|
||||
}
|
||||
)
|
||||
sheet = self.env["hr_timesheet.sheet"].create(
|
||||
{
|
||||
"employee_id": employee.id,
|
||||
"company_id": company.id,
|
||||
"date_start": WEEK_START,
|
||||
"date_end": WEEK_END,
|
||||
}
|
||||
)
|
||||
sheet._compute_timesheet_ids()
|
||||
return {
|
||||
"company": company,
|
||||
"user": user,
|
||||
"employee": employee,
|
||||
"leave": leave,
|
||||
"sheet": sheet,
|
||||
}
|
||||
|
||||
def _invoice(self):
|
||||
journal = self.env["account.journal"].create(
|
||||
{
|
||||
"name": "Cascade Test Journal",
|
||||
"code": "CTJ",
|
||||
"type": "general",
|
||||
"company_id": self.company.id,
|
||||
}
|
||||
)
|
||||
return self.env["account.move"].create(
|
||||
{
|
||||
"move_type": "entry",
|
||||
"journal_id": journal.id,
|
||||
"company_id": self.company.id,
|
||||
}
|
||||
)
|
||||
|
||||
# Fixture sanity
|
||||
|
||||
def test_fixture(self):
|
||||
self.assertEqual(len(self.leave_lines), 3)
|
||||
self.assertEqual(self.leave.state, "validate")
|
||||
self.assertEqual(len(self.leave_span_lines), 4)
|
||||
self.assertEqual(
|
||||
self.sheet.timesheet_ids, self.leave_lines | self.regular_line
|
||||
)
|
||||
|
||||
# RF-01
|
||||
|
||||
def test_wizard_preview_does_not_delete(self):
|
||||
wizard = self._wizard(self.sheet)
|
||||
self.assertEqual(wizard.sheet_count, 1)
|
||||
self.assertEqual(wizard.timesheet_count, len(self.sheet.timesheet_ids))
|
||||
self.assertEqual(
|
||||
wizard.timesheet_hours,
|
||||
sum(self.sheet.timesheet_ids.mapped("unit_amount")),
|
||||
)
|
||||
self.assertIn(self.sheet.complete_name, wizard.summary_html)
|
||||
self.assertTrue(self.sheet.exists())
|
||||
self.assertEqual(self.sheet.timesheet_ids.exists(), self.sheet.timesheet_ids)
|
||||
|
||||
def test_wizard_action_opens_on_selected_sheets(self):
|
||||
action = self.sheet.with_user(self.user).action_delete_sheet()
|
||||
self.assertEqual(action["res_model"], "hr_timesheet.sheet.delete.wizard")
|
||||
self.assertEqual(action["context"]["active_ids"], self.sheet.ids)
|
||||
|
||||
# RF-02
|
||||
|
||||
def test_cascade_deletes_timesheets(self):
|
||||
self._set_policy("detach")
|
||||
sheet = self.sheet_other
|
||||
line_a = self._new_regular_line(self.employee_other, 2.0)
|
||||
line_b = self._new_regular_line(self.employee_other, 3.0)
|
||||
sheet._compute_timesheet_ids()
|
||||
line_ids = sheet.timesheet_ids.ids
|
||||
self.assertEqual(sorted(line_ids), sorted((line_a | line_b).ids))
|
||||
self._wizard(sheet, user=self.user_other).action_confirm_delete()
|
||||
self.assertFalse(sheet.exists())
|
||||
self.assertFalse(self.env["account.analytic.line"].browse(line_ids).exists())
|
||||
|
||||
def test_bare_unlink_is_refused(self):
|
||||
with self.assertRaises(UserError):
|
||||
self.sheet.with_user(self.user).unlink()
|
||||
self.assertTrue(self.sheet.exists())
|
||||
|
||||
# RF-03
|
||||
|
||||
def test_user_cannot_delete_other_sheet(self):
|
||||
other = self.sheet_other.with_user(self.user)
|
||||
with self.assertRaises(AccessError):
|
||||
other.check_access_rule("unlink")
|
||||
with self.assertRaises(AccessError):
|
||||
other.check_access_rule("write")
|
||||
|
||||
def test_user_can_delete_own_sheet(self):
|
||||
own = self.sheet.with_user(self.user)
|
||||
own.check_access_rule("unlink")
|
||||
own.check_access_rule("write")
|
||||
|
||||
def test_internal_user_without_timesheet_group_cannot_delete_other_sheet(self):
|
||||
self.assertFalse(
|
||||
self.user_internal.has_group("hr_timesheet.group_hr_timesheet_user")
|
||||
)
|
||||
sheet = self.sheet.with_user(self.user_internal)
|
||||
with self.assertRaises(AccessError):
|
||||
sheet.check_access_rule("unlink")
|
||||
with self.assertRaises(AccessError):
|
||||
sheet.check_access_rule("write")
|
||||
|
||||
def test_manager_can_delete_any_sheet(self):
|
||||
sheets = (self.sheet | self.sheet_other).with_user(self.user_manager)
|
||||
sheets.check_access_rule("unlink")
|
||||
sheets.check_access_rule("write")
|
||||
|
||||
# RF-04
|
||||
|
||||
def test_invoiced_timesheets_block_deletion(self):
|
||||
self.regular_line.timesheet_invoice_id = self._invoice()
|
||||
wizard = self._wizard(self.sheet)
|
||||
self.assertEqual(wizard.invoiced_count, 1)
|
||||
self.assertTrue(wizard.blocking_reason)
|
||||
self.assertIn(self.sheet.complete_name, wizard.blocking_reason)
|
||||
self.assertIn("1", wizard.blocking_reason)
|
||||
with self.assertRaises(UserError):
|
||||
wizard.action_confirm_delete()
|
||||
with self.assertRaises(UserError):
|
||||
self._cascade(self.sheet.with_user(self.user)).unlink()
|
||||
self.assertTrue(self.sheet.exists())
|
||||
|
||||
# RF-05
|
||||
|
||||
def test_policy_default_is_detach(self):
|
||||
company = self.env["res.company"].create({"name": "Fresh Cascade Co"})
|
||||
self.assertEqual(company.timesheet_sheet_delete_leave_policy, "detach")
|
||||
self.assertEqual(self.sheet._sheet_leave_policy(), "detach")
|
||||
|
||||
def test_policy_detach_keeps_leave(self):
|
||||
self._set_policy("detach")
|
||||
leave_line_ids = self.leave_lines.ids
|
||||
self._wizard(self.sheet).action_confirm_delete()
|
||||
self.assertFalse(self.sheet.exists())
|
||||
self.assertFalse(self.regular_line.exists())
|
||||
lines = self.env["account.analytic.line"].browse(leave_line_ids)
|
||||
self.assertEqual(lines.exists(), lines)
|
||||
self.assertFalse(lines.mapped("sheet_id"))
|
||||
self.assertTrue(self.leave.exists())
|
||||
self.assertEqual(self.leave.state, "validate")
|
||||
|
||||
def test_policy_detach_keeps_balance(self):
|
||||
self._set_policy("detach")
|
||||
taken, remaining = self._leave_balance(self.employee)
|
||||
self._wizard(self.sheet).action_confirm_delete()
|
||||
self.assertEqual(self._leave_balance(self.employee), (taken, remaining))
|
||||
|
||||
# RF-06
|
||||
|
||||
def test_cascade_cancels_leaves_and_restores_balance(self):
|
||||
self._set_policy("cancel")
|
||||
taken, remaining = self._leave_balance(self.employee)
|
||||
self.assertEqual(taken, 3.0)
|
||||
leave_line_ids = self.leave_lines.ids
|
||||
leave = self.leave
|
||||
self._wizard(self.sheet).action_confirm_delete()
|
||||
self.assertFalse(self.sheet.exists())
|
||||
self.assertFalse(
|
||||
self.env["account.analytic.line"].browse(leave_line_ids).exists()
|
||||
)
|
||||
self.assertFalse(self.regular_line.exists())
|
||||
self.assertFalse(leave.exists())
|
||||
new_taken, new_remaining = self._leave_balance(self.employee)
|
||||
self.assertEqual(new_taken, taken - 3.0)
|
||||
self.assertEqual(new_remaining, remaining + 3.0)
|
||||
|
||||
# RF-07
|
||||
|
||||
def test_wizard_lists_leaves(self):
|
||||
self._set_policy("cancel")
|
||||
wizard = self._wizard(self.sheet)
|
||||
self.assertEqual(wizard.leave_policy, "cancel")
|
||||
self.assertEqual(wizard.leave_count, len(self.leave_lines))
|
||||
self.assertEqual(wizard.leave_days, 3.0)
|
||||
self.assertEqual(wizard.leave_ids, self.leave)
|
||||
self.assertFalse(wizard.partial_leave_ids)
|
||||
self.assertIn(self.employee.name, wizard.summary_html)
|
||||
self.assertIn(self.leave_type.name, wizard.summary_html)
|
||||
|
||||
def test_wizard_warns_about_leaves_spanning_sheets(self):
|
||||
self._set_policy("cancel")
|
||||
sheet = self._span_sheet()
|
||||
wizard = self._wizard(sheet, user=self.user_span)
|
||||
self.assertEqual(wizard.partial_leave_ids, self.leave_span)
|
||||
self.assertIn("Warning", wizard.summary_html)
|
||||
self.assertIn(self.employee_span.name, wizard.summary_html)
|
||||
|
||||
def test_wizard_detach_policy_states_leaves_untouched(self):
|
||||
self._set_policy("detach")
|
||||
wizard = self._wizard(self.sheet)
|
||||
self.assertEqual(wizard.leave_policy, "detach")
|
||||
self.assertEqual(wizard.leave_count, len(self.leave_lines))
|
||||
self.assertEqual(wizard.leave_days, 0.0)
|
||||
self.assertFalse(wizard.leave_ids)
|
||||
self.assertFalse(wizard.partial_leave_ids)
|
||||
self.assertIn("balances do not change", wizard.summary_html)
|
||||
|
||||
def test_wizard_without_leaves_has_no_leave_section(self):
|
||||
line = self._new_regular_line(self.employee_other, 2.0)
|
||||
sheet = self.sheet_other
|
||||
sheet._compute_timesheet_ids()
|
||||
self.assertEqual(sheet.timesheet_ids, line)
|
||||
for policy in ("detach", "cancel"):
|
||||
self._set_policy(policy)
|
||||
wizard = self._wizard(sheet, user=self.user_other)
|
||||
self.assertEqual(wizard.leave_count, 0)
|
||||
self.assertNotIn("Time off", wizard.summary_html)
|
||||
|
||||
def test_uncancellable_leave_blocks_deletion(self):
|
||||
self._set_policy("cancel")
|
||||
# A request already refused outside this module keeps its entries:
|
||||
# action_refuse() would have removed them, a data fix would not.
|
||||
self.leave.with_user(SUPERUSER_ID).sudo().write({"state": "refuse"})
|
||||
self.assertEqual(self.sheet._sheet_leaves_to_cancel(), self.env["hr.leave"])
|
||||
self.assertEqual(self.sheet._sheet_leaves_not_cancellable(), self.leave)
|
||||
wizard = self._wizard(self.sheet)
|
||||
self.assertTrue(wizard.blocking_reason)
|
||||
self.assertIn(self.sheet.complete_name, wizard.blocking_reason)
|
||||
self.assertIn("cannot be cancelled", wizard.blocking_reason)
|
||||
with self.assertRaises(UserError):
|
||||
wizard.action_confirm_delete()
|
||||
self.env.invalidate_all()
|
||||
self.assertTrue(self.sheet.exists())
|
||||
self.assertEqual(self.sheet.timesheet_ids.exists(), self.sheet.timesheet_ids)
|
||||
|
||||
def test_multicompany_batch_summary_matches_each_policy(self):
|
||||
self._set_policy("cancel")
|
||||
other = self._second_company_sheet_with_leave("detach")
|
||||
self.env.user.write({"company_ids": [(4, other["company"].id)]})
|
||||
sheets = self.sheet | other["sheet"]
|
||||
data = sheets._delete_cascade_prepare()
|
||||
titles = [section["title"] for section in data["sections"]]
|
||||
# Compared against the titles the model itself produces, so the test
|
||||
# does not depend on the language of the user running it.
|
||||
self.assertIn(other["sheet"]._delete_cascade_detach_section()["title"], titles)
|
||||
self.assertIn(self.sheet._delete_cascade_cancel_section()[0]["title"], titles)
|
||||
self.assertEqual(data["leave_days"], 3.0)
|
||||
self.assertEqual(data["leave_count"], 5)
|
||||
cancel_title = self.sheet._delete_cascade_cancel_section()[0]["title"]
|
||||
cancelled = " ".join(
|
||||
line
|
||||
for section in data["sections"]
|
||||
if section["title"] == cancel_title
|
||||
for line in section["lines"]
|
||||
)
|
||||
self.assertIn(self.employee.name, cancelled)
|
||||
self.assertNotIn(other["employee"].name, cancelled)
|
||||
wizard = self._wizard(sheets, user=self.env.user)
|
||||
self.assertEqual(wizard.leave_ids, self.leave)
|
||||
self.assertEqual(wizard.leave_days, 3.0)
|
||||
|
||||
def test_public_holiday_lines_created_while_cancelling_are_deleted(self):
|
||||
self._set_policy("cancel")
|
||||
global_leave = (
|
||||
self.env["resource.calendar.leaves"]
|
||||
.with_context(allowed_company_ids=[self.company.id])
|
||||
.create(
|
||||
{
|
||||
"name": "Public holiday",
|
||||
"calendar_id": self.company.resource_calendar_id.id,
|
||||
"company_id": self.company.id,
|
||||
"date_from": datetime(2026, 10, 7, 0, 0, 0),
|
||||
"date_to": datetime(2026, 10, 7, 23, 59, 0),
|
||||
}
|
||||
)
|
||||
)
|
||||
self.assertFalse(global_leave.timesheet_ids)
|
||||
sheet_id = self.sheet.id
|
||||
self._wizard(self.sheet).action_confirm_delete()
|
||||
self.env.flush_all()
|
||||
self.env.invalidate_all()
|
||||
self.assertFalse(self.sheet.exists())
|
||||
self.assertFalse(global_leave.timesheet_ids)
|
||||
self.env.cr.execute(
|
||||
"SELECT COUNT(*) FROM account_analytic_line WHERE sheet_id = %s",
|
||||
(sheet_id,),
|
||||
)
|
||||
self.assertEqual(self.env.cr.fetchone()[0], 0)
|
||||
|
||||
# RF-08
|
||||
|
||||
def test_bare_unlink_of_leave_line_is_refused(self):
|
||||
line = self.leave_lines[0].with_user(self.user)
|
||||
with self.assertRaises(UserError):
|
||||
line.unlink()
|
||||
self.env.invalidate_all()
|
||||
self.assertTrue(self.leave_lines[0].exists())
|
||||
self.assertTrue(self.leave_lines[0].sheet_id)
|
||||
|
||||
def test_unlink_during_sheet_recompute_detaches(self):
|
||||
line = self.leave_lines[0].with_user(self.user)
|
||||
self._recompute(line).unlink()
|
||||
self.env.invalidate_all()
|
||||
self.assertTrue(self.leave_lines[0].exists())
|
||||
self.assertFalse(self.leave_lines[0].sheet_id)
|
||||
self.assertTrue(self.leave.exists())
|
||||
|
||||
def test_unlink_during_delete_cascade_cancel_deletes(self):
|
||||
self._set_policy("cancel")
|
||||
line = self.leave_lines[0]
|
||||
self._cascade(line.with_user(self.user).sudo()).unlink()
|
||||
self.assertFalse(line.exists())
|
||||
|
||||
def test_unlink_during_delete_cascade_detach_detaches(self):
|
||||
self._set_policy("detach")
|
||||
line = self.leave_lines[0]
|
||||
self._cascade(line.with_user(self.user).sudo()).unlink()
|
||||
self.env.invalidate_all()
|
||||
self.assertTrue(line.exists())
|
||||
self.assertFalse(line.sheet_id)
|
||||
self.assertTrue(self.leave.exists())
|
||||
|
||||
def test_delete_cascade_flag_without_sudo_is_refused(self):
|
||||
line = self.leave_lines[0].with_user(self.user)
|
||||
for policy in ("detach", "cancel"):
|
||||
self._set_policy(policy)
|
||||
with self.assertRaises(UserError):
|
||||
self._cascade(line).unlink()
|
||||
self.env.invalidate_all()
|
||||
self.assertTrue(self.leave_lines[0].exists())
|
||||
self.assertTrue(self.leave_lines[0].sheet_id)
|
||||
|
||||
def test_both_flags_delete_cascade_wins(self):
|
||||
self._set_policy("cancel")
|
||||
line = self.leave_lines[0]
|
||||
self._cascade(self._recompute(line.with_user(self.user).sudo())).unlink()
|
||||
self.assertFalse(line.exists())
|
||||
|
||||
def test_both_flags_with_detach_policy_still_detaches(self):
|
||||
self._set_policy("detach")
|
||||
line = self.leave_lines[0]
|
||||
self._cascade(self._recompute(line.with_user(self.user).sudo())).unlink()
|
||||
self.env.invalidate_all()
|
||||
self.assertTrue(line.exists())
|
||||
self.assertFalse(line.sheet_id)
|
||||
|
||||
# RF-09
|
||||
|
||||
def test_no_cascade_flag_leak_after_deletion(self):
|
||||
self._set_policy("cancel")
|
||||
self._wizard(self.sheet).action_confirm_delete()
|
||||
self.assertIsNone(self.env.context.get("sheet_delete_cascade"))
|
||||
other_line = self.leave_span_lines[0].with_user(self.user_span)
|
||||
with self.assertRaises(UserError):
|
||||
other_line.unlink()
|
||||
self.env.invalidate_all()
|
||||
self.assertTrue(self.leave_span_lines[0].exists())
|
||||
self.assertIsNone(self.env.context.get("sheet_delete_cascade"))
|
||||
|
||||
# Published hooks
|
||||
|
||||
def test_delete_cascade_prepare_contract(self):
|
||||
data = self.sheet._delete_cascade_prepare()
|
||||
self.assertEqual(
|
||||
set(data),
|
||||
{
|
||||
"sheet_count",
|
||||
"timesheet_count",
|
||||
"timesheet_hours",
|
||||
"invoiced_count",
|
||||
"leave_count",
|
||||
"leave_days",
|
||||
"leave_policy",
|
||||
"blocking",
|
||||
"sections",
|
||||
},
|
||||
)
|
||||
self.assertEqual(data["blocking"], [])
|
||||
self.assertTrue(all("title" in s and "lines" in s for s in data["sections"]))
|
||||
|
||||
def test_detach_hook_matrix(self):
|
||||
"""One assertion per row of the RF-08 table, on the same leave line.
|
||||
|
||||
The base recordset is bound to a plain employee on purpose: the class
|
||||
env is superuser, so a line taken straight from the fixture would carry
|
||||
env.su and the row that must fail without it would pass for the wrong
|
||||
reason.
|
||||
"""
|
||||
line = self.leave_lines[0].with_user(self.user)
|
||||
self.assertFalse(line.env.su)
|
||||
line_su = line.sudo()
|
||||
|
||||
# Row 1: no flags.
|
||||
self.assertFalse(line._detach_leave_lines_on_unlink())
|
||||
# Row 2: sheet recompute, any policy.
|
||||
self.assertTrue(self._recompute(line)._detach_leave_lines_on_unlink())
|
||||
|
||||
self._set_policy("cancel")
|
||||
# Row 3: deliberate cascade with cancel deletes instead of detaching.
|
||||
self.assertFalse(self._cascade(line_su)._detach_leave_lines_on_unlink())
|
||||
# Row 5, cancel: the sentinel alone, without env.su, detaches nothing.
|
||||
self.assertFalse(self._cascade(line)._detach_leave_lines_on_unlink())
|
||||
self.assertFalse(self._cascade(line)._in_deliberate_delete_cascade())
|
||||
# Row 6: the cascade wins over a concurrent recompute.
|
||||
self.assertFalse(
|
||||
self._cascade(self._recompute(line_su))._detach_leave_lines_on_unlink()
|
||||
)
|
||||
|
||||
self._set_policy("detach")
|
||||
# Row 4: deliberate cascade with detach keeps the line alive.
|
||||
self.assertTrue(self._cascade(line_su)._detach_leave_lines_on_unlink())
|
||||
# Row 5, detach: still nothing without env.su.
|
||||
self.assertFalse(self._cascade(line)._detach_leave_lines_on_unlink())
|
||||
self.assertFalse(self._cascade(line)._in_deliberate_delete_cascade())
|
||||
|
||||
def test_context_token_is_not_serializable(self):
|
||||
token = self.env["account.analytic.line"]._sheet_delete_cascade_token()
|
||||
with self.assertRaises(TypeError):
|
||||
json.dumps(token)
|
||||
|
||||
def test_forged_cascade_flag_does_not_delete_sheet(self):
|
||||
model = self.env["hr_timesheet.sheet"].with_user(self.user)
|
||||
with self.assertRaises(UserError):
|
||||
call_kw(
|
||||
model,
|
||||
"unlink",
|
||||
[self.sheet.ids],
|
||||
{"context": {"sheet_delete_cascade": True}},
|
||||
)
|
||||
self.env.invalidate_all()
|
||||
self.assertTrue(self.sheet.exists())
|
||||
self.assertEqual(self.sheet.timesheet_ids.exists(), self.sheet.timesheet_ids)
|
||||
self.assertFalse(
|
||||
model.with_context(sheet_delete_cascade=True)._in_delete_cascade()
|
||||
)
|
||||
|
||||
def test_forged_cascade_flag_does_not_delete_leave_line(self):
|
||||
model = self.env["account.analytic.line"].with_user(self.user)
|
||||
line = self.leave_lines[0]
|
||||
self._set_policy("cancel")
|
||||
with self.assertRaises(UserError):
|
||||
call_kw(
|
||||
model,
|
||||
"unlink",
|
||||
[[line.id]],
|
||||
{"context": {"sheet_delete_cascade": True}},
|
||||
)
|
||||
self.env.invalidate_all()
|
||||
self.assertTrue(line.exists())
|
||||
self.assertFalse(
|
||||
model.sudo()
|
||||
.with_context(sheet_delete_cascade=True)
|
||||
._in_deliberate_delete_cascade()
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue