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
2
hr_timesheet_sheet_no_review/__init__.py
Normal file
2
hr_timesheet_sheet_no_review/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from . import models
|
||||
from . import wizards
|
||||
23
hr_timesheet_sheet_no_review/__manifest__.py
Normal file
23
hr_timesheet_sheet_no_review/__manifest__.py
Normal 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,
|
||||
}
|
||||
|
|
@ -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 ""
|
||||
1
hr_timesheet_sheet_no_review/models/__init__.py
Normal file
1
hr_timesheet_sheet_no_review/models/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import hr_timesheet_sheet
|
||||
48
hr_timesheet_sheet_no_review/models/hr_timesheet_sheet.py
Normal file
48
hr_timesheet_sheet_no_review/models/hr_timesheet_sheet.py
Normal 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
|
||||
3
hr_timesheet_sheet_no_review/readme/CONTRIBUTORS.rst
Normal file
3
hr_timesheet_sheet_no_review/readme/CONTRIBUTORS.rst
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
* Criptomart
|
||||
|
||||
* Luis Martín <luis@criptomart.net>
|
||||
20
hr_timesheet_sheet_no_review/readme/DESCRIPTION.rst
Normal file
20
hr_timesheet_sheet_no_review/readme/DESCRIPTION.rst
Normal 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.
|
||||
31
hr_timesheet_sheet_no_review/readme/ROADMAP.rst
Normal file
31
hr_timesheet_sheet_no_review/readme/ROADMAP.rst
Normal 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.
|
||||
22
hr_timesheet_sheet_no_review/readme/USAGE.rst
Normal file
22
hr_timesheet_sheet_no_review/readme/USAGE.rst
Normal 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")]``.
|
||||
|
|
@ -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
hr_timesheet_sheet_no_review/tests/__init__.py
Normal file
1
hr_timesheet_sheet_no_review/tests/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import test_hr_timesheet_sheet_no_review
|
||||
|
|
@ -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({}))
|
||||
103
hr_timesheet_sheet_no_review/views/hr_timesheet_sheet_views.xml
Normal file
103
hr_timesheet_sheet_no_review/views/hr_timesheet_sheet_views.xml
Normal 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>
|
||||
35
hr_timesheet_sheet_no_review/views/menus.xml
Normal file
35
hr_timesheet_sheet_no_review/views/menus.xml
Normal 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>
|
||||
1
hr_timesheet_sheet_no_review/wizards/__init__.py
Normal file
1
hr_timesheet_sheet_no_review/wizards/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import unfreeze_wizard
|
||||
79
hr_timesheet_sheet_no_review/wizards/unfreeze_wizard.py
Normal file
79
hr_timesheet_sheet_no_review/wizards/unfreeze_wizard.py
Normal 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"},
|
||||
},
|
||||
}
|
||||
|
|
@ -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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue