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,2 @@
from . import models
from . import wizards

View file

@ -0,0 +1,23 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
{
"name": "HR Timesheet Sheet - No Review Workflow",
"version": "16.0.1.0.0",
"category": "Human Resources",
"summary": "Hide the timesheet sheet review workflow and unfreeze pending sheets",
"author": "Criptomart, Odoo Community Association (OCA)",
"website": "https://github.com/OCA/timesheet",
"license": "AGPL-3",
"development_status": "Beta",
"depends": ["hr_timesheet_sheet"],
"data": [
"security/ir.model.access.csv",
"wizards/unfreeze_wizard_views.xml",
"views/hr_timesheet_sheet_views.xml",
"views/menus.xml",
],
"installable": True,
"auto_install": False,
"application": False,
}

View file

@ -0,0 +1,139 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * hr_timesheet_sheet_no_review
#
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"
"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_no_review
#. odoo-python
#: code:addons/hr_timesheet_sheet_no_review/wizards/unfreeze_wizard.py:0
#, python-format
msgid "%s timesheet sheet(s) have been set back to draft."
msgstr ""
#. module: hr_timesheet_sheet_no_review
#: model:ir.model.fields,field_description:hr_timesheet_sheet_no_review.field_hr_timesheet_sheet_unfreeze_wizard__timesheet_hours
msgid "Blocked Hours"
msgstr ""
#. module: hr_timesheet_sheet_no_review
#: model:ir.model.fields,field_description:hr_timesheet_sheet_no_review.field_hr_timesheet_sheet_unfreeze_wizard__timesheet_count
msgid "Blocked Timesheets"
msgstr ""
#. module: hr_timesheet_sheet_no_review
#: model_terms:ir.ui.view,arch_db:hr_timesheet_sheet_no_review.hr_timesheet_sheet_unfreeze_wizard_form
msgid "Cancel"
msgstr ""
#. module: hr_timesheet_sheet_no_review
#: model:ir.model.fields,field_description:hr_timesheet_sheet_no_review.field_hr_timesheet_sheet_unfreeze_wizard__create_uid
msgid "Created by"
msgstr ""
#. module: hr_timesheet_sheet_no_review
#: model:ir.model.fields,field_description:hr_timesheet_sheet_no_review.field_hr_timesheet_sheet_unfreeze_wizard__create_date
msgid "Created on"
msgstr ""
#. module: hr_timesheet_sheet_no_review
#: model:ir.model.fields,field_description:hr_timesheet_sheet_no_review.field_hr_timesheet_sheet_unfreeze_wizard__display_name
msgid "Display Name"
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_count
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__id
msgid "ID"
msgstr ""
#. module: hr_timesheet_sheet_no_review
#: model:ir.model.fields,field_description:hr_timesheet_sheet_no_review.field_hr_timesheet_sheet_unfreeze_wizard____last_update
msgid "Last Modified on"
msgstr ""
#. module: hr_timesheet_sheet_no_review
#: model:ir.model.fields,field_description:hr_timesheet_sheet_no_review.field_hr_timesheet_sheet_unfreeze_wizard__write_uid
msgid "Last Updated by"
msgstr ""
#. module: hr_timesheet_sheet_no_review
#: model:ir.model.fields,field_description:hr_timesheet_sheet_no_review.field_hr_timesheet_sheet_unfreeze_wizard__write_date
msgid "Last Updated on"
msgstr ""
#. module: hr_timesheet_sheet_no_review
#. odoo-python
#: code:addons/hr_timesheet_sheet_no_review/wizards/unfreeze_wizard.py:0
#, python-format
msgid "No frozen timesheet sheet was found. Nothing was changed."
msgstr ""
#. module: hr_timesheet_sheet_no_review
#: model:ir.model.fields,field_description:hr_timesheet_sheet_no_review.field_hr_timesheet_sheet_unfreeze_wizard__summary_html
msgid "Summary"
msgstr ""
#. module: hr_timesheet_sheet_no_review
#. odoo-python
#: code:addons/hr_timesheet_sheet_no_review/wizards/unfreeze_wizard.py:0
#, python-format
msgid ""
"The timesheet sheets frozen by the review workflow, and the timesheet "
"entries they keep locked, are:"
msgstr ""
#. module: hr_timesheet_sheet_no_review
#. odoo-python
#: code:addons/hr_timesheet_sheet_no_review/wizards/unfreeze_wizard.py:0
#, python-format
msgid "There is no frozen timesheet sheet. Nothing to unfreeze."
msgstr ""
#. module: hr_timesheet_sheet_no_review
#: model_terms:ir.ui.view,arch_db:hr_timesheet_sheet_no_review.hr_timesheet_sheet_unfreeze_wizard_form
msgid ""
"This is a mass operation on historical data and it cannot be\n"
" undone: the reviewer of every approved sheet is cleared. Make\n"
" sure you have a recent backup before you continue."
msgstr ""
#. module: hr_timesheet_sheet_no_review
#: model:ir.model,name:hr_timesheet_sheet_no_review.model_hr_timesheet_sheet
msgid "Timesheet Sheet"
msgstr ""
#. module: hr_timesheet_sheet_no_review
#: model_terms:ir.ui.view,arch_db:hr_timesheet_sheet_no_review.hr_timesheet_sheet_unfreeze_wizard_form
msgid "Unfreeze"
msgstr ""
#. module: hr_timesheet_sheet_no_review
#. odoo-python
#: code:addons/hr_timesheet_sheet_no_review/wizards/unfreeze_wizard.py:0
#: model:ir.actions.act_window,name:hr_timesheet_sheet_no_review.action_hr_timesheet_sheet_unfreeze_wizard
#: model:ir.ui.menu,name:hr_timesheet_sheet_no_review.menu_hr_timesheet_sheet_unfreeze
#: model_terms:ir.ui.view,arch_db:hr_timesheet_sheet_no_review.hr_timesheet_sheet_unfreeze_wizard_form
#, python-format
msgid "Unfreeze Pending Sheets"
msgstr ""
#. module: hr_timesheet_sheet_no_review
#: model:ir.model,name:hr_timesheet_sheet_no_review.model_hr_timesheet_sheet_unfreeze_wizard
msgid "Unfreeze Pending Timesheet Sheets"
msgstr ""

View file

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

View file

@ -0,0 +1,48 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
import logging
from odoo import api, models
_logger = logging.getLogger(__name__)
class HrTimesheetSheet(models.Model):
_inherit = "hr_timesheet.sheet"
@api.model
def _unfreeze_domain(self):
"""Hook for extensions: which sheets are considered frozen."""
return [("state", "in", ("confirm", "done"))]
@api.model
def _unfreeze_sheets(self):
"""Send frozen sheets back to draft. Returns the number of sheets affected.
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())
if not sheets:
_logger.info("No frozen timesheet sheets to unfreeze.")
return 0
sheets.check_access_rights("write")
sheets.check_access_rule("write")
self.env.flush_all()
self.env.cr.execute(
"""
UPDATE hr_timesheet_sheet
SET state = 'draft',
reviewer_id = NULL,
write_uid = %s,
write_date = (now() at time zone 'UTC')
WHERE id IN %s
""",
(self.env.uid, tuple(sheets.ids)),
)
count = self.env.cr.rowcount
self.env.invalidate_all(flush=False)
_logger.info("Unfroze %s timesheet sheet(s).", count)
return count

View file

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

View file

@ -0,0 +1,20 @@
``hr_timesheet_sheet`` ships a review workflow: the employee submits the sheet,
a reviewer approves or refuses it and may send it back to draft. Many
organisations never use it — they record hours and do not submit them for
approval.
The workflow is not harmless when it is not used. ``_check_state()`` forbids
modifying the timesheet entries of any sheet that is not in ``new`` or
``draft``. A sheet submitted by mistake and never attended freezes its entries
forever: nobody can edit them, and ``action_timesheet_draft()`` only works from
``done``, never from ``confirm``.
This module removes the review workflow from the user interface — buttons,
kanban actions, search filters, the department counter of sheets to approve
and the menus that only exist to serve it — and adds a wizard that sends the
sheets that got stuck in ``confirm`` or ``done`` back to draft.
The ``action_timesheet_*`` methods of the base module are left untouched, so
the rest of the family (``hr_timesheet_sheet_attendance``,
``hr_timesheet_sheet_policy_*``, ``hr_timesheet_sheet_autodraft``) keeps
working. They simply no longer have an entry point in the interface.

View file

@ -0,0 +1,31 @@
Hiding the review workflow is not the same as forbidding it. The
``action_timesheet_*`` methods stay callable from code, from a server action or
from another module, so a sheet can still go back to ``confirm`` through a
non-interactive path. That is deliberate, for compatibility with the rest of the
family. An installation that needs to really forbid it would need a separate
module.
The modules of the family that take the review workflow for granted lose their
purpose with this one installed. They do not break, because the methods are
still there, but they become unreachable from the user interface:
* ``hr_timesheet_sheet_policy_department_manager``
* ``hr_timesheet_sheet_policy_project_manager``
* ``hr_timesheet_sheet_autodraft``
* the ``action_timesheet_confirm`` check of ``hr_timesheet_sheet_attendance``
Unfreezing cannot be undone. Uninstalling the module brings the buttons back,
but the sheets that were already set back to draft stay in draft, and the four
menus of the review workflow stay deactivated: they belong to the base module
and Odoo does not restore a value written on a foreign record.
The ``Timesheets to Review`` action of the department kanban loses both of its
entry points, but the action record itself survives, because
``ir.actions.act_window`` has no ``active`` field to switch off. It is only
reachable from Settings > Technical > Actions, and its stale
``search_default_to_review`` context key no longer matches any filter.
With the sheets back in ``draft``, every save triggers the base module's
``delete_empty_lines(True)``, which removes lines with no description and no
hours. This is pre-existing behaviour, but it may prune historical empty
entries the first time those sheets are opened.

View file

@ -0,0 +1,22 @@
Once installed, the review buttons are gone from the timesheet sheet form and
kanban; the ``Submitted``, ``To Review`` and ``Reviewer`` entries are gone from
the sheet search view; the ``Not Submitted`` filter is gone from the timesheet
search view; the department kanban no longer counts the sheets to approve; and
the ``To Review`` and ``Timesheets to Submit`` menus are deactivated.
To release the sheets that were left frozen by the review workflow, go to
*Timesheets > Timesheets > Unfreeze Pending Sheets*. The wizard first shows how
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.
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.
Take a backup before the first run: the operation touches every frozen sheet at
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")]``.

View file

@ -0,0 +1,2 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_hr_timesheet_sheet_unfreeze_wizard,hr_timesheet.sheet.unfreeze.wizard,model_hr_timesheet_sheet_unfreeze_wizard,hr_timesheet.group_timesheet_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_hr_timesheet_sheet_unfreeze_wizard hr_timesheet.sheet.unfreeze.wizard model_hr_timesheet_sheet_unfreeze_wizard hr_timesheet.group_timesheet_manager 1 1 1 1

View file

@ -0,0 +1 @@
from . import test_hr_timesheet_sheet_no_review

View file

@ -0,0 +1,358 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
import os
from unittest.mock import patch
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
MODULE = "hr_timesheet_sheet_no_review"
REVIEW_ACTIONS = [
"action_timesheet_confirm",
"action_timesheet_done",
"action_timesheet_draft",
"action_timesheet_refuse",
]
REVIEW_MENUS = [
"hr_timesheet_sheet.menu_hr_to_review",
"hr_timesheet_sheet.menu_act_hr_timesheet_sheet_to_review",
"hr_timesheet_sheet.menu_act_hr_timesheet_line_to_submit",
"hr_timesheet_sheet.menu_act_hr_timesheet_line_to_submit_my",
]
class TestHrTimesheetSheetNoReview(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.sheet_model = cls.env["hr_timesheet.sheet"]
cls.line_model = cls.env["account.analytic.line"]
cls.wizard_model = cls.env["hr_timesheet.sheet.unfreeze.wizard"]
cls.company = cls.env["res.company"].create({"name": "No Review Company"})
cls.env.user.company_ids += cls.company
cls.user = (
cls.env["res.users"]
.with_context(no_reset_password=True, default_role_line_ids=[])
.create(
{
"name": "No Review Employee",
"login": "no_review_employee",
"email": "no_review_employee@example.org",
"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,
],
)
],
}
)
)
cls.manager = (
cls.env["res.users"]
.with_context(no_reset_password=True, default_role_line_ids=[])
.create(
{
"name": "No Review Manager",
"login": "no_review_manager",
"email": "no_review_manager@example.org",
"company_id": cls.company.id,
"company_ids": [(6, 0, [cls.company.id])],
"groups_id": [
(
6,
0,
[
cls.env.ref("hr_timesheet.group_timesheet_manager").id,
cls.env.ref("project.group_project_manager").id,
],
)
],
}
)
)
cls.reviewer = (
cls.env["res.users"]
.with_context(no_reset_password=True, default_role_line_ids=[])
.create(
{
"name": "No Review Reviewer",
"login": "no_review_reviewer",
"email": "no_review_reviewer@example.org",
"company_id": cls.company.id,
"company_ids": [(6, 0, [cls.company.id])],
"groups_id": [
(
6,
0,
[
cls.env.ref("hr.group_hr_user").id,
cls.env.ref("hr_timesheet.group_hr_timesheet_user").id,
cls.env.ref("project.group_project_user").id,
],
)
],
}
)
)
cls.employee = cls.env["hr.employee"].create(
{
"name": "No Review Employee",
"user_id": cls.user.id,
"company_id": cls.company.id,
}
)
cls.env["hr.employee"].create(
{
"name": "No Review Reviewer",
"user_id": cls.reviewer.id,
"company_id": cls.company.id,
}
)
cls.project = cls.env["project.project"].create(
{
"name": "No Review Project",
"company_id": cls.company.id,
"allow_timesheets": True,
}
)
cls.task = cls.env["project.task"].create(
{
"name": "No Review Task",
"project_id": cls.project.id,
"company_id": cls.company.id,
}
)
cls.baseline = cls._frozen_figures(cls.env)
cls.sheet = cls.sheet_model.create(
{"employee_id": cls.employee.id, "company_id": cls.company.id}
)
cls.line = cls.line_model.create(
{
"name": "Frozen entry",
"date": cls.sheet.date_start,
"project_id": cls.project.id,
"task_id": cls.task.id,
"employee_id": cls.employee.id,
"company_id": cls.company.id,
"unit_amount": 3.0,
}
)
cls.sheet.action_timesheet_confirm()
@staticmethod
def _frozen_figures(env):
sheet_model = env["hr_timesheet.sheet"]
sheets = sheet_model.search(sheet_model._unfreeze_domain())
groups = env["account.analytic.line"].read_group(
[("sheet_id", "in", sheets.ids)], ["unit_amount"], []
)
return {
"sheets": len(sheets),
"timesheets": groups[0]["__count"] if groups else 0,
"hours": (groups[0]["unit_amount"] if groups else 0.0) or 0.0,
}
def _arch(self, xml_id, view_type, model="hr_timesheet.sheet"):
view = self.env.ref(xml_id)
return etree.fromstring(
self.env[model].get_view(view_id=view.id, view_type=view_type)["arch"]
)
# RF-01
def test_review_buttons_are_hidden(self):
for xml_id in (
"hr_timesheet_sheet.hr_timesheet_sheet_form",
"hr_timesheet_sheet.hr_timesheet_sheet_form_my",
):
arch = self._arch(xml_id, "form")
for action in REVIEW_ACTIONS:
self.assertFalse(
arch.xpath("//button[@name='%s']" % action),
"%s still exposes %s" % (xml_id, action),
)
state = arch.xpath("//header/field[@name='state']")
self.assertTrue(state)
self.assertEqual(state[0].get("statusbar_visible"), "draft")
def test_review_buttons_are_hidden_in_kanban(self):
arch = self._arch("hr_timesheet_sheet.view_hr_timesheet_sheet_kanban", "kanban")
for action in REVIEW_ACTIONS:
self.assertFalse(arch.xpath("//a[@name='%s']" % action))
def test_reviewer_is_hidden_in_lists(self):
for xml_id in (
"hr_timesheet_sheet.hr_timesheet_sheet_tree",
"hr_timesheet_sheet.hr_timesheet_sheet_tree_my",
):
arch = self._arch(xml_id, "tree")
self.assertFalse(arch.xpath("//field[@name='reviewer_id']"))
self.assertIsNone(arch.xpath("//tree")[0].get("decoration-muted"))
def test_review_filters_are_hidden_in_search(self):
arch = self._arch("hr_timesheet_sheet.view_hr_timesheet_sheet_filter", "search")
self.assertFalse(arch.xpath("//filter[@name='confirm']"))
self.assertFalse(arch.xpath("//filter[@name='to_review']"))
self.assertFalse(arch.xpath("//search/field[@name='reviewer_id']"))
self.assertFalse(arch.xpath("//filter[@name='groupby_reviewer']"))
def test_submission_filter_is_hidden_in_timesheet_search(self):
arch = self._arch(
"hr_timesheet.hr_timesheet_line_search",
"search",
model="account.analytic.line",
)
self.assertFalse(arch.xpath("//filter[@name='unsubmitted']"))
self.assertFalse(
arch.xpath("//search/separator[not(preceding-sibling::filter)]"),
"removing the filter must not leave its separator behind",
)
def test_department_kanban_has_no_review_counter(self):
arch = self._arch(
"hr.hr_department_view_kanban", "kanban", model="hr.department"
)
rendered = etree.tostring(arch).decode()
self.assertNotIn("timesheet_sheet_to_approve_count", rendered)
action = self.env.ref("hr_timesheet_sheet.hr_timesheet_action_from_department")
self.assertFalse(arch.xpath("//a[@name='%s']" % action.id))
def test_review_menus_are_inactive(self):
for xml_id in REVIEW_MENUS:
self.assertFalse(self.env.ref(xml_id).active, xml_id)
def test_review_methods_are_kept(self):
"""The workflow is gone from the interface but still works from code.
The rest of the family (``..._policy_*``, ``..._autodraft``,
``..._attendance``) calls these methods, so they must stay functional.
"""
sheet = self.sheet.with_user(self.reviewer)
self.assertEqual(sheet.state, "confirm")
sheet.action_timesheet_done()
self.assertEqual(sheet.state, "done")
self.assertEqual(sheet.reviewer_id.user_id, self.reviewer)
sheet.action_timesheet_draft()
self.assertEqual(sheet.state, "draft")
self.assertFalse(sheet.reviewer_id)
self.sheet.with_user(self.user).action_timesheet_confirm()
self.assertEqual(sheet.state, "confirm")
sheet.action_timesheet_refuse()
self.assertEqual(sheet.state, "draft")
self.assertFalse(sheet.reviewer_id)
# RF-02
def test_install_does_not_change_sheet_states(self):
manifest = get_manifest(MODULE)
for key in ("pre_init_hook", "post_init_hook", "uninstall_hook", "post_load"):
self.assertFalse(
manifest.get(key), "%s must not declare a %s" % (MODULE, key)
)
module_path = get_module_path(MODULE)
for data_file in manifest["data"]:
with open(os.path.join(module_path, data_file)) as handle:
self.assertNotRegex(
handle.read(),
r"<\s*function",
"%s must not call a method on install" % data_file,
)
records = self.env["ir.model.data"].search([("module", "=", MODULE)])
self.assertTrue(records)
business_models = records.filtered(
lambda record: not record.model.startswith("ir.")
)
self.assertFalse(
business_models.mapped("model"),
"%s must only install technical records" % MODULE,
)
def test_wizard_reports_pending_sheets(self):
wizard = self.wizard_model.create({})
self.assertEqual(wizard.sheet_count, self.baseline["sheets"] + 1)
self.assertEqual(wizard.timesheet_count, self.baseline["timesheets"] + 1)
self.assertAlmostEqual(
wizard.timesheet_hours, self.baseline["hours"] + 3.0, places=2
)
self.assertNotIn("Nothing to unfreeze", wizard.summary_html)
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)
self.assertIn("Nothing to unfreeze", wizard.summary_html)
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()
self.assertEqual(self.sheet.state, "draft")
self.assertFalse(self.sheet.reviewer_id)
self.assertEqual(
self.sheet_model.search_count([("state", "in", ("confirm", "done"))]), 0
)
self.assertIn(str(expected), result["params"]["message"])
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()
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_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()
self.line.with_user(self.user).write({"unit_amount": 5.0})
self.assertEqual(self.line.unit_amount, 5.0)
def test_unfreeze_domain_hook(self):
self.assertEqual(
self.sheet_model._unfreeze_domain(),
[("state", "in", ("confirm", "done"))],
)
kept_id = self.sheet.id
with patch.object(
type(self.sheet_model),
"_unfreeze_domain",
lambda records: [
("state", "in", ("confirm", "done")),
("id", "!=", kept_id),
],
):
self.sheet_model._unfreeze_sheets()
self.assertEqual(self.sheet.state, "confirm")
self.assertEqual(
self.sheet_model.search_count([("state", "in", ("confirm", "done"))]), 1
)
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({}))

View file

@ -0,0 +1,103 @@
<?xml version="1.0" encoding="utf-8" ?>
<!--
Copyright 2026 Criptomart
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
-->
<odoo>
<record id="hr_timesheet_sheet_form" model="ir.ui.view">
<field name="name">hr_timesheet.sheet.form.no.review</field>
<field name="model">hr_timesheet.sheet</field>
<field name="inherit_id" ref="hr_timesheet_sheet.hr_timesheet_sheet_form" />
<field name="arch" type="xml">
<xpath
expr="//header/button[@name='action_timesheet_confirm']"
position="replace"
/>
<xpath
expr="//header/button[@name='action_timesheet_done']"
position="replace"
/>
<xpath
expr="//header/button[@name='action_timesheet_draft']"
position="replace"
/>
<xpath
expr="//header/button[@name='action_timesheet_refuse']"
position="replace"
/>
<xpath expr="//header/field[@name='state']" position="attributes">
<attribute name="statusbar_visible">draft</attribute>
</xpath>
</field>
</record>
<record id="view_hr_timesheet_sheet_kanban" model="ir.ui.view">
<field name="name">hr_timesheet.sheet.kanban.no.review</field>
<field name="model">hr_timesheet.sheet</field>
<field
name="inherit_id"
ref="hr_timesheet_sheet.view_hr_timesheet_sheet_kanban"
/>
<field name="arch" type="xml">
<xpath expr="//a[@name='action_timesheet_confirm']" position="replace" />
<xpath expr="//a[@name='action_timesheet_done']" position="replace" />
<xpath expr="//a[@name='action_timesheet_refuse']" position="replace" />
<xpath expr="//a[@name='action_timesheet_draft']" position="replace" />
</field>
</record>
<record id="hr_timesheet_sheet_tree" model="ir.ui.view">
<field name="name">hr_timesheet.sheet.tree.no.review</field>
<field name="model">hr_timesheet.sheet</field>
<field name="inherit_id" ref="hr_timesheet_sheet.hr_timesheet_sheet_tree" />
<field name="arch" type="xml">
<xpath expr="//tree/field[@name='reviewer_id']" position="replace" />
<xpath expr="//tree" position="attributes">
<attribute name="decoration-muted" />
</xpath>
</field>
</record>
<record id="view_hr_timesheet_sheet_filter" model="ir.ui.view">
<field name="name">hr_timesheet.sheet.filter.no.review</field>
<field name="model">hr_timesheet.sheet</field>
<field
name="inherit_id"
ref="hr_timesheet_sheet.view_hr_timesheet_sheet_filter"
/>
<field name="arch" type="xml">
<xpath expr="//search/filter[@name='confirm']" position="replace" />
<xpath expr="//search/filter[@name='to_review']" position="replace" />
<xpath expr="//search/field[@name='reviewer_id']" position="replace" />
<xpath expr="//filter[@name='groupby_reviewer']" position="replace" />
</field>
</record>
<record id="hr_timesheet_line_search" model="ir.ui.view">
<field name="name">account.analytic.line.search.no.review</field>
<field name="model">account.analytic.line</field>
<field name="inherit_id" ref="hr_timesheet_sheet.hr_timesheet_line_search" />
<field name="arch" type="xml">
<xpath
expr="//search/filter[@name='unsubmitted']/following-sibling::separator[1]"
position="replace"
/>
<xpath expr="//search/filter[@name='unsubmitted']" position="replace" />
</field>
</record>
<record id="hr_department_view_kanban" model="ir.ui.view">
<field name="name">hr.department.kanban.no.review</field>
<field name="model">hr.department</field>
<field name="inherit_id" ref="hr_timesheet_sheet.hr_department_view_kanban" />
<field name="arch" type="xml">
<xpath
expr="//field[@name='timesheet_sheet_to_approve_count']"
position="replace"
/>
<xpath
expr="//div[starts-with(@t-if, 'record.timesheet_sheet_to_approve_count')]"
position="replace"
/>
<xpath
expr="//div[hasclass('o_kanban_manage_reports')]/div[.//t[starts-with(@t-esc, 'record.timesheet_sheet_to_approve')]]"
position="replace"
/>
</field>
</record>
</odoo>

View file

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8" ?>
<!--
Copyright 2026 Criptomart
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
-->
<odoo>
<record id="hr_timesheet_sheet.menu_hr_to_review" model="ir.ui.menu">
<field name="active" eval="False" />
</record>
<record
id="hr_timesheet_sheet.menu_act_hr_timesheet_sheet_to_review"
model="ir.ui.menu"
>
<field name="active" eval="False" />
</record>
<record
id="hr_timesheet_sheet.menu_act_hr_timesheet_line_to_submit"
model="ir.ui.menu"
>
<field name="active" eval="False" />
</record>
<record
id="hr_timesheet_sheet.menu_act_hr_timesheet_line_to_submit_my"
model="ir.ui.menu"
>
<field name="active" eval="False" />
</record>
<menuitem
id="menu_hr_timesheet_sheet_unfreeze"
action="action_hr_timesheet_sheet_unfreeze_wizard"
parent="hr_timesheet.menu_hr_time_tracking"
groups="hr_timesheet.group_timesheet_manager"
sequence="99"
/>
</odoo>

View file

@ -0,0 +1 @@
from . import unfreeze_wizard

View file

@ -0,0 +1,79 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from markupsafe import Markup
from odoo import _, api, fields, models
class HrTimesheetSheetUnfreezeWizard(models.TransientModel):
_name = "hr_timesheet.sheet.unfreeze.wizard"
_description = "Unfreeze Pending Timesheet Sheets"
sheet_count = fields.Integer(
string="Frozen Sheets",
compute="_compute_frozen_data",
)
timesheet_count = fields.Integer(
string="Blocked Timesheets",
compute="_compute_frozen_data",
)
timesheet_hours = fields.Float(
string="Blocked Hours",
compute="_compute_frozen_data",
)
summary_html = fields.Html(
string="Summary",
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.
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
for wizard in self:
wizard.sheet_count = len(sheets)
wizard.timesheet_count = timesheet_count
wizard.timesheet_hours = timesheet_hours
@api.depends("sheet_count", "timesheet_count", "timesheet_hours")
def _compute_summary_html(self):
for wizard in self:
wizard.summary_html = wizard._build_summary_html()
def _build_summary_html(self):
self.ensure_one()
if not self.sheet_count:
return Markup("<p>%s</p>") % _(
"There is no frozen timesheet sheet. Nothing to unfreeze."
)
return Markup("<p>%s</p>") % _(
"The timesheet sheets frozen by the review workflow, and the "
"timesheet entries they keep locked, are:"
)
def action_unfreeze(self):
self.ensure_one()
count = self.env["hr_timesheet.sheet"]._unfreeze_sheets()
if count:
message = _("%s timesheet sheet(s) have been set back to draft.", count)
else:
message = _("No frozen timesheet sheet was found. Nothing was changed.")
return {
"type": "ir.actions.client",
"tag": "display_notification",
"params": {
"title": _("Unfreeze Pending Sheets"),
"message": message,
"type": "success" if count else "info",
"sticky": False,
"next": {"type": "ir.actions.act_window_close"},
},
}

View file

@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8" ?>
<!--
Copyright 2026 Criptomart
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
-->
<odoo>
<record id="hr_timesheet_sheet_unfreeze_wizard_form" model="ir.ui.view">
<field name="name">hr_timesheet.sheet.unfreeze.wizard.form</field>
<field name="model">hr_timesheet.sheet.unfreeze.wizard</field>
<field name="arch" type="xml">
<form string="Unfreeze Pending Sheets">
<field name="summary_html" nolabel="1" readonly="1" />
<group attrs="{'invisible': [('sheet_count', '=', 0)]}">
<field name="sheet_count" />
<field name="timesheet_count" />
<field name="timesheet_hours" widget="float_time" />
</group>
<div
class="alert alert-warning"
role="alert"
attrs="{'invisible': [('sheet_count', '=', 0)]}"
>
This is a mass operation on historical data and it cannot be
undone: the reviewer of every approved sheet is cleared. Make
sure you have a recent backup before you continue.
</div>
<footer>
<button
name="action_unfreeze"
string="Unfreeze"
type="object"
class="btn-primary"
attrs="{'invisible': [('sheet_count', '=', 0)]}"
/>
<button string="Cancel" class="btn-secondary" special="cancel" />
</footer>
</form>
</field>
</record>
<record
id="action_hr_timesheet_sheet_unfreeze_wizard"
model="ir.actions.act_window"
>
<field name="name">Unfreeze Pending Sheets</field>
<field name="res_model">hr_timesheet.sheet.unfreeze.wizard</field>
<field name="view_mode">form</field>
<field name="view_id" ref="hr_timesheet_sheet_unfreeze_wizard_form" />
<field name="target">new</field>
</record>
</odoo>