hr_timesheet_sheet* fixes: back to list after remove a timesheet. bulk unfreeze confirmed and pending sheets
This commit is contained in:
parent
8b0d0bb6cc
commit
0323b6672e
11 changed files with 354 additions and 34 deletions
|
|
@ -6,8 +6,8 @@ msgid ""
|
|||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server 16.0\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-09-15 07:59+0000\n"
|
||||
"PO-Revision-Date: 2026-09-15 07:59+0000\n"
|
||||
"POT-Creation-Date: 2026-09-30 16:47+0000\n"
|
||||
"PO-Revision-Date: 2026-09-30 16:47+0000\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: \n"
|
||||
"MIME-Version: 1.0\n"
|
||||
|
|
@ -57,6 +57,11 @@ msgstr ""
|
|||
msgid "Frozen Sheets"
|
||||
msgstr ""
|
||||
|
||||
#. module: hr_timesheet_sheet_no_review
|
||||
#: model:ir.model.fields,field_description:hr_timesheet_sheet_no_review.field_hr_timesheet_sheet_unfreeze_wizard__sheet_ids
|
||||
msgid "Frozen Sheets To Release"
|
||||
msgstr ""
|
||||
|
||||
#. module: hr_timesheet_sheet_no_review
|
||||
#: model:ir.model.fields,field_description:hr_timesheet_sheet_no_review.field_hr_timesheet_sheet_unfreeze_wizard__id
|
||||
msgid "ID"
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
import logging
|
||||
|
||||
from odoo import api, models
|
||||
from odoo.osv import expression
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -17,14 +18,24 @@ class HrTimesheetSheet(models.Model):
|
|||
return [("state", "in", ("confirm", "done"))]
|
||||
|
||||
@api.model
|
||||
def _unfreeze_sheets(self):
|
||||
def _unfreeze_sheets(self, sheets=None):
|
||||
"""Send frozen sheets back to draft. Returns the number of sheets affected.
|
||||
|
||||
Without ``sheets``, every frozen sheet the current user can see is
|
||||
released, which is the historical behaviour of this hook. With
|
||||
``sheets``, only those of them that are still frozen are released: the
|
||||
wizard passes the recordset it showed the user, so the confirmation
|
||||
message counts what the warning announced and not what the database
|
||||
happens to hold by the time the button is pressed.
|
||||
|
||||
The update is issued in SQL on purpose: going through ``write()`` would
|
||||
trigger the sheet recomputation and one tracking message per sheet, on a
|
||||
volume that is measured in thousands of records.
|
||||
"""
|
||||
sheets = self.search(self._unfreeze_domain())
|
||||
domain = self._unfreeze_domain()
|
||||
if sheets is not None:
|
||||
domain = expression.AND([domain, [("id", "in", sheets.ids)]])
|
||||
sheets = self.search(domain)
|
||||
if not sheets:
|
||||
_logger.info("No frozen timesheet sheets to unfreeze.")
|
||||
return 0
|
||||
|
|
|
|||
|
|
@ -9,6 +9,12 @@ To release the sheets that were left frozen by the review workflow, go to
|
|||
many sheets, how many timesheet entries and how many hours are about to be
|
||||
released. Confirming sets those sheets back to draft and clears their reviewer.
|
||||
|
||||
The wizard releases exactly the sheets it listed when it was opened, and the
|
||||
confirmation message counts them. A sheet frozen by somebody else while the
|
||||
dialog was on screen is not released: it shows up the next time the wizard is
|
||||
opened. Only the frozen sheets the user can see are listed, so on a
|
||||
multi-company database the figures follow the company switcher.
|
||||
|
||||
Installing the module never changes the state of a sheet on its own: unfreezing
|
||||
is always an explicit decision of the timesheet manager. Running the wizard
|
||||
twice is safe — the second run reports zero sheets and changes nothing.
|
||||
|
|
@ -19,4 +25,11 @@ once and the reviewer of the approved ones is lost.
|
|||
To decide which sheets count as frozen, override
|
||||
``hr_timesheet.sheet._unfreeze_domain()``. For instance, an installation that
|
||||
wants to keep its approved sheets as historical record can narrow it down to
|
||||
``[("state", "=", "confirm")]``.
|
||||
``[("state", "=", "confirm")]``. The domain is applied twice: once to list the
|
||||
sheets in the dialog, and again when the button is pressed, so a sheet it keeps
|
||||
out is never released.
|
||||
|
||||
``hr_timesheet.sheet._unfreeze_sheets()`` takes an optional recordset. Called
|
||||
with no argument it releases every frozen sheet, as before; called with a
|
||||
recordset it releases only those of them that the domain still considers
|
||||
frozen.
|
||||
|
|
|
|||
|
|
@ -4,11 +4,12 @@
|
|||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from lxml import etree
|
||||
|
||||
from odoo.exceptions import AccessError, UserError
|
||||
from odoo.modules.module import get_manifest, get_module_path
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.tests.common import Form, TransactionCase
|
||||
|
||||
MODULE = "hr_timesheet_sheet_no_review"
|
||||
REVIEW_ACTIONS = [
|
||||
|
|
@ -285,7 +286,62 @@ class TestHrTimesheetSheetNoReview(TransactionCase):
|
|||
"%s must only install technical records" % MODULE,
|
||||
)
|
||||
|
||||
def test_wizard_reports_pending_sheets(self):
|
||||
# The figures reach the interface
|
||||
|
||||
def test_wizard_reports_pending_sheets_in_the_interface(self):
|
||||
"""The path the user walks: default_get() + onchange(), no create().
|
||||
|
||||
Opening an action with ``target="new"`` never creates the record: the
|
||||
client builds a virtual one and the server decides what to compute from
|
||||
the dependency graph. A compute with no ``@api.depends`` is skipped and
|
||||
the dialog reports zero on a database full of frozen sheets.
|
||||
"""
|
||||
form = Form(self.wizard_model)
|
||||
self.assertEqual(form.sheet_count, self.baseline["sheets"] + 1)
|
||||
self.assertEqual(form.timesheet_count, self.baseline["timesheets"] + 1)
|
||||
self.assertAlmostEqual(
|
||||
form.timesheet_hours, self.baseline["hours"] + 3.0, places=2
|
||||
)
|
||||
self.assertNotIn("Nothing to unfreeze", form.summary_html)
|
||||
self.assertIn(self.sheet, form.sheet_ids)
|
||||
|
||||
def test_wizard_reports_pending_sheets_through_raw_onchange(self):
|
||||
"""Same flow, called the way the web client calls it.
|
||||
|
||||
``Form`` is a faithful client, but it is still test code. This drives
|
||||
``onchange()`` directly with the field list of the real view, so the
|
||||
test also fails if the view stops exposing ``sheet_ids`` and
|
||||
``default_get()`` is therefore never asked for it.
|
||||
"""
|
||||
arch = self._arch(
|
||||
"hr_timesheet_sheet_no_review.hr_timesheet_sheet_unfreeze_wizard_form",
|
||||
"form",
|
||||
model=self.wizard_model._name,
|
||||
)
|
||||
names = [node.get("name") for node in arch.xpath("//field")]
|
||||
self.assertIn("sheet_ids", names)
|
||||
values = self.wizard_model.onchange({}, [], {name: "1" for name in names})[
|
||||
"value"
|
||||
]
|
||||
self.assertEqual(values["sheet_count"], self.baseline["sheets"] + 1)
|
||||
self.assertEqual(values["timesheet_count"], self.baseline["timesheets"] + 1)
|
||||
self.assertAlmostEqual(
|
||||
values["timesheet_hours"], self.baseline["hours"] + 3.0, places=2
|
||||
)
|
||||
self.assertNotIn("Nothing to unfreeze", values["summary_html"])
|
||||
|
||||
def test_wizard_figures_depend_on_a_real_field(self):
|
||||
"""Guard: the figures must keep a dependency to be computed at all."""
|
||||
for name in ("sheet_count", "timesheet_count", "timesheet_hours"):
|
||||
field = self.wizard_model._fields[name]
|
||||
self.assertEqual(
|
||||
self.env.registry.field_depends[field],
|
||||
("sheet_ids",),
|
||||
"%s must depend on sheet_ids or it is never computed in the UI" % name,
|
||||
)
|
||||
|
||||
def test_wizard_reports_pending_sheets_from_code(self):
|
||||
"""``create({})`` is the shell path, used by the diagnosis probe."""
|
||||
wizard = self.wizard_model.create({})
|
||||
self.assertEqual(wizard.sheet_count, self.baseline["sheets"] + 1)
|
||||
self.assertEqual(wizard.timesheet_count, self.baseline["timesheets"] + 1)
|
||||
|
|
@ -296,17 +352,22 @@ class TestHrTimesheetSheetNoReview(TransactionCase):
|
|||
|
||||
def test_wizard_reports_nothing_to_unfreeze(self):
|
||||
self.sheet_model._unfreeze_sheets()
|
||||
wizard = self.wizard_model.create({})
|
||||
self.assertEqual(wizard.sheet_count, 0)
|
||||
self.assertEqual(wizard.timesheet_count, 0)
|
||||
self.assertEqual(wizard.timesheet_hours, 0.0)
|
||||
form = Form(self.wizard_model)
|
||||
self.assertEqual(form.sheet_count, 0)
|
||||
self.assertEqual(form.timesheet_count, 0)
|
||||
self.assertEqual(form.timesheet_hours, 0.0)
|
||||
self.assertEqual(len(form.sheet_ids), 0)
|
||||
self.assertIn("Nothing to unfreeze", form.summary_html)
|
||||
wizard = form.save()
|
||||
self.assertIn("Nothing to unfreeze", wizard.summary_html)
|
||||
|
||||
# Unfreezing
|
||||
|
||||
def test_unfreeze_releases_sheets(self):
|
||||
self.assertEqual(self.sheet.state, "confirm")
|
||||
wizard = self.wizard_model.create({})
|
||||
expected = wizard.sheet_count
|
||||
result = wizard.action_unfreeze()
|
||||
form = Form(self.wizard_model)
|
||||
expected = form.sheet_count
|
||||
result = form.save().action_unfreeze()
|
||||
self.assertEqual(self.sheet.state, "draft")
|
||||
self.assertFalse(self.sheet.reviewer_id)
|
||||
self.assertEqual(
|
||||
|
|
@ -314,24 +375,105 @@ class TestHrTimesheetSheetNoReview(TransactionCase):
|
|||
)
|
||||
self.assertIn(str(expected), result["params"]["message"])
|
||||
|
||||
def test_unfreeze_only_releases_the_sheets_it_announced(self):
|
||||
"""The confirmation must count what the warning announced.
|
||||
|
||||
The wizard is built, and only then another sheet is frozen. That sheet
|
||||
was not on screen, so it is not released and it is not counted.
|
||||
"""
|
||||
wizard = Form(self.wizard_model).save()
|
||||
announced = wizard.sheet_count
|
||||
|
||||
late_sheet = self.sheet_model.create(
|
||||
{
|
||||
"employee_id": self.employee.id,
|
||||
"company_id": self.company.id,
|
||||
"date_start": self.sheet.date_start + relativedelta(months=1),
|
||||
"date_end": self.sheet.date_end + relativedelta(months=1),
|
||||
}
|
||||
)
|
||||
late_sheet.action_timesheet_confirm()
|
||||
|
||||
result = wizard.action_unfreeze()
|
||||
self.assertIn(str(announced), result["params"]["message"])
|
||||
self.assertEqual(self.sheet.state, "draft")
|
||||
self.assertEqual(late_sheet.state, "confirm")
|
||||
|
||||
def test_unfreeze_ignores_sheets_outside_the_domain(self):
|
||||
"""Operating on a given recordset must not relax the domain.
|
||||
|
||||
``sheet_ids`` comes from the client and can be tampered with. The
|
||||
sheets that ``_unfreeze_domain()`` keeps out stay out, whatever the
|
||||
wizard is told to release.
|
||||
"""
|
||||
draft_sheet = self.sheet_model.create(
|
||||
{
|
||||
"employee_id": self.employee.id,
|
||||
"company_id": self.company.id,
|
||||
"date_start": self.sheet.date_start + relativedelta(months=2),
|
||||
"date_end": self.sheet.date_end + relativedelta(months=2),
|
||||
}
|
||||
)
|
||||
self.assertEqual(draft_sheet.state, "draft")
|
||||
kept_id = self.sheet.id
|
||||
with patch.object(
|
||||
type(self.sheet_model),
|
||||
"_unfreeze_domain",
|
||||
lambda records: [
|
||||
("state", "in", ("confirm", "done")),
|
||||
("id", "!=", kept_id),
|
||||
],
|
||||
):
|
||||
self.assertNotIn(self.sheet, Form(self.wizard_model).sheet_ids)
|
||||
wizard = self.wizard_model.create(
|
||||
{"sheet_ids": [(6, 0, (self.sheet + draft_sheet).ids)]}
|
||||
)
|
||||
result = wizard.action_unfreeze()
|
||||
self.assertEqual(self.sheet.state, "confirm")
|
||||
self.assertEqual(draft_sheet.state, "draft")
|
||||
self.assertEqual(result["params"]["type"], "info")
|
||||
|
||||
def test_unfreeze_is_idempotent(self):
|
||||
self.assertTrue(self.sheet_model._unfreeze_sheets())
|
||||
self.assertEqual(self.sheet_model._unfreeze_sheets(), 0)
|
||||
wizard = self.wizard_model.create({})
|
||||
result = wizard.action_unfreeze()
|
||||
result = Form(self.wizard_model).save().action_unfreeze()
|
||||
self.assertEqual(
|
||||
self.sheet_model.search_count([("state", "in", ("confirm", "done"))]), 0
|
||||
)
|
||||
self.assertEqual(result["params"]["type"], "info")
|
||||
self.assertEqual(self.sheet.state, "draft")
|
||||
|
||||
def test_unfreeze_twice_from_the_interface_is_idempotent(self):
|
||||
first = Form(self.wizard_model).save()
|
||||
second = Form(self.wizard_model).save()
|
||||
self.assertTrue(first.action_unfreeze()["params"]["type"] == "success")
|
||||
result = second.action_unfreeze()
|
||||
self.assertEqual(result["params"]["type"], "info")
|
||||
self.assertEqual(self.sheet.state, "draft")
|
||||
self.assertFalse(self.sheet.reviewer_id)
|
||||
|
||||
def test_unfrozen_lines_are_editable(self):
|
||||
with self.assertRaises(UserError):
|
||||
self.line.with_user(self.user).write({"unit_amount": 5.0})
|
||||
self.wizard_model.create({}).action_unfreeze()
|
||||
Form(self.wizard_model).save().action_unfreeze()
|
||||
self.line.with_user(self.user).write({"unit_amount": 5.0})
|
||||
self.assertEqual(self.line.unit_amount, 5.0)
|
||||
|
||||
def test_unfreeze_sheets_hook_keeps_its_signature(self):
|
||||
"""``_unfreeze_sheets()`` is public: calling it with no argument must
|
||||
keep releasing every frozen sheet."""
|
||||
self.assertEqual(
|
||||
self.sheet_model._unfreeze_sheets(), self.baseline["sheets"] + 1
|
||||
)
|
||||
self.assertEqual(self.sheet.state, "draft")
|
||||
|
||||
def test_unfreeze_sheets_accepts_a_recordset(self):
|
||||
self.assertEqual(self.sheet_model._unfreeze_sheets(self.sheet), 1)
|
||||
self.assertEqual(self.sheet.state, "draft")
|
||||
self.assertEqual(self.sheet_model._unfreeze_sheets(self.sheet), 0)
|
||||
no_sheet = self.sheet_model.browse()
|
||||
self.assertEqual(self.sheet_model._unfreeze_sheets(no_sheet), 0)
|
||||
|
||||
def test_unfreeze_domain_hook(self):
|
||||
self.assertEqual(
|
||||
self.sheet_model._unfreeze_domain(),
|
||||
|
|
@ -355,4 +497,6 @@ class TestHrTimesheetSheetNoReview(TransactionCase):
|
|||
def test_wizard_is_restricted_to_managers(self):
|
||||
with self.assertRaises(AccessError):
|
||||
self.wizard_model.with_user(self.user).create({})
|
||||
self.assertTrue(self.wizard_model.with_user(self.manager).create({}))
|
||||
manager_form = Form(self.wizard_model.with_user(self.manager))
|
||||
self.assertTrue(manager_form.sheet_count)
|
||||
self.assertTrue(manager_form.save())
|
||||
|
|
|
|||
|
|
@ -10,6 +10,10 @@ class HrTimesheetSheetUnfreezeWizard(models.TransientModel):
|
|||
_name = "hr_timesheet.sheet.unfreeze.wizard"
|
||||
_description = "Unfreeze Pending Timesheet Sheets"
|
||||
|
||||
sheet_ids = fields.Many2many(
|
||||
comodel_name="hr_timesheet.sheet",
|
||||
string="Frozen Sheets To Release",
|
||||
)
|
||||
sheet_count = fields.Integer(
|
||||
string="Frozen Sheets",
|
||||
compute="_compute_frozen_data",
|
||||
|
|
@ -27,21 +31,36 @@ class HrTimesheetSheetUnfreezeWizard(models.TransientModel):
|
|||
compute="_compute_summary_html",
|
||||
)
|
||||
|
||||
# No @api.depends: the figures come from the state of the whole database,
|
||||
# not from any field of the wizard. The wizard is created anew every time
|
||||
# the action is opened, so they are computed once, when the dialog is read.
|
||||
@api.model
|
||||
def default_get(self, fields_list):
|
||||
res = super().default_get(fields_list)
|
||||
if "sheet_ids" in fields_list:
|
||||
sheet_model = self.env["hr_timesheet.sheet"]
|
||||
sheets = sheet_model.search(sheet_model._unfreeze_domain())
|
||||
res["sheet_ids"] = [fields.Command.set(sheets.ids)]
|
||||
return res
|
||||
|
||||
@api.depends("sheet_ids")
|
||||
def _compute_frozen_data(self):
|
||||
sheet_model = self.env["hr_timesheet.sheet"]
|
||||
sheets = sheet_model.search(sheet_model._unfreeze_domain())
|
||||
groups = self.env["account.analytic.line"].read_group(
|
||||
[("sheet_id", "in", sheets.ids)], ["unit_amount"], []
|
||||
)
|
||||
timesheet_count = groups[0]["__count"] if groups else 0
|
||||
timesheet_hours = (groups[0]["unit_amount"] if groups else 0.0) or 0.0
|
||||
"""The figures describe ``sheet_ids``, not the database.
|
||||
|
||||
Depending on a real field is what makes the figures appear in the
|
||||
interface: opening an action on a transient model builds the record
|
||||
with ``default_get()`` + ``onchange()``, and that flow seeds every
|
||||
field of the view into the cache before computing anything. A compute
|
||||
with no dependency is then a cache hit on the default value, and the
|
||||
dialog reports zero.
|
||||
"""
|
||||
line_model = self.env["account.analytic.line"]
|
||||
for wizard in self:
|
||||
sheets = wizard.sheet_ids
|
||||
groups = line_model.read_group(
|
||||
[("sheet_id", "in", sheets.ids)], ["unit_amount"], []
|
||||
)
|
||||
group = groups[0] if groups else {}
|
||||
wizard.sheet_count = len(sheets)
|
||||
wizard.timesheet_count = timesheet_count
|
||||
wizard.timesheet_hours = timesheet_hours
|
||||
wizard.timesheet_count = group.get("__count") or 0
|
||||
wizard.timesheet_hours = group.get("unit_amount") or 0.0
|
||||
|
||||
@api.depends("sheet_count", "timesheet_count", "timesheet_hours")
|
||||
def _compute_summary_html(self):
|
||||
|
|
@ -61,7 +80,7 @@ class HrTimesheetSheetUnfreezeWizard(models.TransientModel):
|
|||
|
||||
def action_unfreeze(self):
|
||||
self.ensure_one()
|
||||
count = self.env["hr_timesheet.sheet"]._unfreeze_sheets()
|
||||
count = self.env["hr_timesheet.sheet"]._unfreeze_sheets(self.sheet_ids)
|
||||
if count:
|
||||
message = _("%s timesheet sheet(s) have been set back to draft.", count)
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -9,6 +9,12 @@
|
|||
<field name="model">hr_timesheet.sheet.unfreeze.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Unfreeze Pending Sheets">
|
||||
<!--
|
||||
The figures depend on sheet_ids, and the onchange flow that
|
||||
builds the dialog only asks default_get() for the fields of
|
||||
the view. Drop this field and the dialog reports zero.
|
||||
-->
|
||||
<field name="sheet_ids" invisible="1" />
|
||||
<field name="summary_html" nolabel="1" readonly="1" />
|
||||
<group attrs="{'invisible': [('sheet_count', '=', 0)]}">
|
||||
<field name="sheet_count" />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue