Initial commit: OCA Mrp packages (117 packages)

This commit is contained in:
Ernad Husremovic 2025-08-29 15:43:05 +02:00
commit 277e84fd7a
4403 changed files with 395154 additions and 0 deletions

View file

@ -0,0 +1,48 @@
# HR Timesheet Sheet
Odoo addon: hr_timesheet_sheet
## Installation
```bash
pip install odoo-bringout-oca-timesheet-hr_timesheet_sheet
```
## Dependencies
This addon depends on:
- hr_timesheet
- sale_timesheet
- web_widget_x2many_2d_matrix
## Manifest Information
- **Name**: HR Timesheet Sheet
- **Version**: 16.0.1.2.0
- **Category**: Human Resources
- **License**: AGPL-3
- **Installable**: True
## Source
Based on [OCA/timesheet](https://github.com/OCA/timesheet) branch 16.0, addon `hr_timesheet_sheet`.
## License
This package maintains the original AGPL-3 license from the upstream Odoo project.
## Documentation
- Overview: doc/OVERVIEW.md
- Architecture: doc/ARCHITECTURE.md
- Models: doc/MODELS.md
- Controllers: doc/CONTROLLERS.md
- Wizards: doc/WIZARDS.md
- Reports: doc/REPORTS.md
- Security: doc/SECURITY.md
- Install: doc/INSTALL.md
- Usage: doc/USAGE.md
- Configuration: doc/CONFIGURATION.md
- Dependencies: doc/DEPENDENCIES.md
- Troubleshooting: doc/TROUBLESHOOTING.md
- FAQ: doc/FAQ.md

View file

@ -0,0 +1,32 @@
# Architecture
```mermaid
flowchart TD
U[Users] -->|HTTP| V[Views and QWeb Templates]
V --> C[Controllers]
V --> W[Wizards Transient Models]
C --> M[Models and ORM]
W --> M
M --> R[Reports]
DX[Data XML] --> M
S[Security ACLs and Groups] -. enforces .-> M
subgraph Hr_timesheet_sheet Module - hr_timesheet_sheet
direction LR
M:::layer
W:::layer
C:::layer
V:::layer
R:::layer
S:::layer
DX:::layer
end
classDef layer fill:#eef8ff,stroke:#6ea8fe,stroke-width:1px
```
Notes
- Views include tree/form/kanban templates and report templates.
- Controllers provide website/portal routes when present.
- Wizards are UI flows implemented with `models.TransientModel`.
- Data XML loads data/demo records; Security defines groups and access.

View file

@ -0,0 +1,3 @@
# Configuration
Refer to Odoo settings for hr_timesheet_sheet. Configure related models, access rights, and options as needed.

View file

@ -0,0 +1,3 @@
# Controllers
This module does not define custom HTTP controllers.

View file

@ -0,0 +1,7 @@
# Dependencies
This addon depends on:
- [hr_timesheet](../../odoo-bringout-oca-ocb-hr_timesheet)
- [sale_timesheet](../../odoo-bringout-oca-ocb-sale_timesheet)
- [web_widget_x2many_2d_matrix](../../odoo-bringout-oca-web-web_widget_x2many_2d_matrix)

View file

@ -0,0 +1,4 @@
# FAQ
- Q: Which Odoo version? A: 16.0 (OCA/OCB packaged).
- Q: How to enable? A: Start server with --addon hr_timesheet_sheet or install in UI.

View file

@ -0,0 +1,7 @@
# Install
```bash
pip install odoo-bringout-oca-timesheet-hr_timesheet_sheet"
# or
uv pip install odoo-bringout-oca-timesheet-hr_timesheet_sheet"
```

View file

@ -0,0 +1,22 @@
# Models
Detected core models and extensions in hr_timesheet_sheet.
```mermaid
classDiagram
class hr_timesheet_sheet
class hr_timesheet_sheet_line
class hr_timesheet_sheet_line_abstract
class hr_timesheet_sheet_new_analytic_line
class account_analytic_account
class account_analytic_line
class hr_department
class hr_employee
class hr_timesheet_sheet_line_abstract
class res_company
class res_config_settings
```
Notes
- Classes show model technical names; fields omitted for brevity.
- Items listed under _inherit are extensions of existing models.

View file

@ -0,0 +1,6 @@
# Overview
Packaged Odoo addon: hr_timesheet_sheet. Provides features documented in upstream Odoo 16 under this addon.
- Source: OCA/OCB 16.0, addon hr_timesheet_sheet
- License: LGPL-3

View file

@ -0,0 +1,3 @@
# Reports
This module does not define custom reports.

View file

@ -0,0 +1,41 @@
# Security
Access control and security definitions in hr_timesheet_sheet.
## Access Control Lists (ACLs)
Model access permissions defined in:
- **[ir.model.access.csv](../hr_timesheet_sheet/security/ir.model.access.csv)**
- 3 model access rules
## Record Rules
Row-level security rules defined in:
## Security Groups & Configuration
Security groups and permissions defined in:
- **[hr_timesheet_sheet_security.xml](../hr_timesheet_sheet/security/hr_timesheet_sheet_security.xml)**
```mermaid
graph TB
subgraph "Security Layers"
A[Users] --> B[Groups]
B --> C[Access Control Lists]
C --> D[Models]
B --> E[Record Rules]
E --> F[Individual Records]
end
```
Security files overview:
- **[hr_timesheet_sheet_security.xml](../hr_timesheet_sheet/security/hr_timesheet_sheet_security.xml)**
- Security groups, categories, and XML-based rules
- **[ir.model.access.csv](../hr_timesheet_sheet/security/ir.model.access.csv)**
- Model access permissions (CRUD rights)
Notes
- Access Control Lists define which groups can access which models
- Record Rules provide row-level security (filter records by user/group)
- Security groups organize users and define permission sets
- All security is enforced at the ORM level by Odoo

View file

@ -0,0 +1,5 @@
# Troubleshooting
- Ensure Python and Odoo environment matches repo guidance.
- Check database connectivity and logs if startup fails.
- Validate that dependent addons listed in DEPENDENCIES.md are installed.

View file

@ -0,0 +1,7 @@
# Usage
Start Odoo including this addon (from repo root):
```bash
python3 scripts/nix_odoo_web_server.py --db-name mydb --addon hr_timesheet_sheet
```

View file

@ -0,0 +1,3 @@
# Wizards
This module does not include UI wizards.

View file

@ -0,0 +1,131 @@
==================
HR Timesheet Sheet
==================
..
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:faddd2bab01c77bceef23115853e6806a0b39685cf857967bba202ca08e84895
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
:target: https://odoo-community.org/page/development-status
:alt: Beta
.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
:alt: License: AGPL-3
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Ftimesheet-lightgray.png?logo=github
:target: https://github.com/OCA/timesheet/tree/16.0/hr_timesheet_sheet
:alt: OCA/timesheet
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
:target: https://translation.odoo-community.org/projects/timesheet-16-0/timesheet-16-0-hr_timesheet_sheet
:alt: Translate me on Weblate
.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png
:target: https://runboat.odoo-community.org/builds?repo=OCA/timesheet&target_branch=16.0
:alt: Try me on Runboat
|badge1| |badge2| |badge3| |badge4| |badge5|
This module supplies a new screen enabling you to manage your work encoding
(timesheet) by period. Timesheet entries are made by employees each day. At the
end of the defined period, employees submit their validated sheet and the
reviewer must then approve submitted entries. Periods are defined in the
company forms and you can set them to run monthly, weekly or daily. By default,
policy is configured to have HR Officers as reviewers.
**Table of contents**
.. contents::
:local:
Installation
============
This module relies on:
* The OCA module '2D matrix for x2many fields', and can be downloaded from
Github: https://github.com/OCA/web/tree/16.0/web_widget_x2many_2d_matrix
Configuration
=============
If you want other default ranges different from weekly, you need to go:
* In the menu `Configuration` -> `Settings` -> **Timesheet Options**,
and select in **Timesheet Sheet Range** the default range you want.
* When you have a weekly range you can also specify the **Week Start Day**.
To change who reviews submitted sheets, go to *Configuration > Settings > Timesheet Options*
and configure **Timesheet Sheet Review Policy** accordingly.
For adding more review policies, look at the *hr_timesheet_sheet_policy_xxx*
extra modules.
Usage
=====
If you modify the `Details` tab, automatically the `Summary` tab is updated.
But if you modify the `Summary` tab, you need to save in order to have the `Details` tab updated.
In case you modify the unit amount of both tabs, the `Details` tab will prevail.
If you modify the `Summary` tab, and you need to do a change in the `Details` tab, please save before.
Known issues / Roadmap
======================
* The timesheet grid is limited to display a max. of 1M cells, due to a
limitation of the tree view limit parameter not being able to dynamically
set a limit. Since default value of odoo, 40 records is too small, we decided
to set 1M, which should be good enough in the majority of scenarios.
Bug Tracker
===========
Bugs are tracked on `GitHub Issues <https://github.com/OCA/timesheet/issues>`_.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us to smash it by providing a detailed and welcomed
`feedback <https://github.com/OCA/timesheet/issues/new?body=module:%20hr_timesheet_sheet%0Aversion:%2016.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.
Do not contact contributors directly about support or help with technical issues.
Credits
=======
Authors
~~~~~~~
* ForgeFlow
* Onestein
* CorporateHub
Contributors
~~~~~~~~~~~~
* Miquel Raïch <miquel.raich@forgeflow.com>
* Andrea Stirpe <a.stirpe@onestein.nl>
* Lois Rilo <lois.rilo@forgeflow.com>
* `CorporateHub <https://corporatehub.eu/>`__
* Alexey Pelykh <alexey.pelykh@corphub.eu>
* Dennis Sluijk <d.sluijk@onestein.nl>
* Sunanda Chhatbar <sunanda.chhatbar@initos.com>
Maintainers
~~~~~~~~~~~
This module is maintained by the OCA.
.. image:: https://odoo-community.org/logo.png
:alt: Odoo Community Association
:target: https://odoo-community.org
OCA, or the Odoo Community Association, is a nonprofit organization whose
mission is to support the collaborative development of Odoo features and
promote its widespread use.
This module is part of the `OCA/timesheet <https://github.com/OCA/timesheet/tree/16.0/hr_timesheet_sheet>`_ project on GitHub.
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

View file

@ -0,0 +1,3 @@
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from . import models

View file

@ -0,0 +1,29 @@
# Copyright 2018 ForgeFlow (https://www.forgeflow.com)
# Copyright 2018-2019 Brainbean Apps (https://brainbeanapps.com)
# Copyright 2018-2019 Onestein (<https://www.onestein.eu>)
# Copyright 2020 CorporateHub (https://corporatehub.eu)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
{
"name": "HR Timesheet Sheet",
"version": "16.0.1.2.0",
"category": "Human Resources",
"sequence": 80,
"summary": "Timesheet Sheets, Activities",
"license": "AGPL-3",
"author": "ForgeFlow, Onestein, CorporateHub, " "Odoo Community Association (OCA)",
"website": "https://github.com/OCA/timesheet",
"installable": True,
"auto_install": False,
"depends": ["hr_timesheet", "sale_timesheet", "web_widget_x2many_2d_matrix"],
"data": [
"data/hr_timesheet_sheet_data.xml",
"security/ir.model.access.csv",
"security/hr_timesheet_sheet_security.xml",
"views/hr_timesheet_sheet_views.xml",
"views/hr_department_views.xml",
"views/hr_employee_views.xml",
"views/account_analytic_line_views.xml",
"views/res_config_settings_views.xml",
],
}

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8" ?>
<!--
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
-->
<odoo>
<!-- Timesheet sheet related subtypes for messaging / Chatter -->
<record id="mt_timesheet_confirmed" model="mail.message.subtype">
<field name="name">Waiting Review</field>
<field name="res_model">hr_timesheet.sheet</field>
<field name="default" eval="True" />
<field name="description">Waiting review</field>
</record>
<record id="mt_timesheet_approved" model="mail.message.subtype">
<field name="name">Approved</field>
<field name="res_model">hr_timesheet.sheet</field>
<field name="default" eval="True" />
<field name="description">Approved</field>
</record>
</odoo>

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,9 @@
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from . import res_company
from . import res_config
from . import account_analytic_account
from . import account_analytic_line
from . import hr_timesheet_sheet
from . import hr_department
from . import hr_employee

View file

@ -0,0 +1,28 @@
# Copyright 2019 Onestein (<https://www.onestein.eu>)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import _, api, models
from odoo.exceptions import ValidationError
class AccountAnalyticAccount(models.Model):
_inherit = "account.analytic.account"
@api.constrains("company_id")
def _check_timesheet_sheet_company_id(self):
for rec in self.sudo():
sheets = rec.line_ids.mapped("sheet_id").filtered(
lambda s: s.company_id and s.company_id != rec.company_id
)
if sheets:
raise ValidationError(
_(
"You cannot change the company, "
"as this %(rec_name)s (%(rec_display_name)s) "
"is assigned to %(current_name)s (%(current_display_name)s).",
rec_name=rec._name,
rec_display_name=rec.display_name,
current_name=sheets[0]._name,
current_display_name=sheets[0].display_name,
)
)

View file

@ -0,0 +1,141 @@
# Copyright 2018 ForgeFlow, S.L.
# Copyright 2018-2019 Brainbean Apps (https://brainbeanapps.com)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import _, api, fields, models
from odoo.exceptions import UserError, ValidationError
class AccountAnalyticLine(models.Model):
_inherit = "account.analytic.line"
sheet_id = fields.Many2one(
comodel_name="hr_timesheet.sheet", string="Sheet", copy=False
)
sheet_state = fields.Selection(string="Sheet State", related="sheet_id.state")
def _get_sheet_domain(self):
"""Hook for extensions"""
self.ensure_one()
return [
("date_end", ">=", self.date),
("date_start", "<=", self.date),
("employee_id", "=", self.employee_id.id),
("company_id", "in", [self.company_id.id, False]),
("state", "in", ["new", "draft"]),
]
def _determine_sheet(self):
"""Hook for extensions"""
self.ensure_one()
return self.env["hr_timesheet.sheet"].search(self._get_sheet_domain(), limit=1)
def _compute_sheet(self):
"""Links the timesheet line to the corresponding sheet"""
for timesheet in self.filtered("project_id"):
sheet = timesheet._determine_sheet()
if timesheet.sheet_id != sheet:
timesheet.sheet_id = sheet
@api.constrains("company_id", "sheet_id")
def _check_company_id_sheet_id(self):
for aal in self.sudo():
if (
aal.company_id
and aal.sheet_id.company_id
and aal.company_id != aal.sheet_id.company_id
):
raise ValidationError(
_(
"You cannot create a timesheet of a different company "
"than the one of the timesheet sheet:"
"\n - %(sheet_name)s of %(sheet_company)s"
"\n - %(name)s of %(company)s",
sheet_name=aal.sheet_id.complete_name,
sheet_company=aal.sheet_id.company_id.name,
name=aal.name,
company=aal.company_id.name,
)
)
@api.model_create_multi
def create(self, values):
if not self.env.context.get("sheet_create") and "sheet_id" in values:
del values["sheet_id"]
res = super().create(values)
res._compute_sheet()
return res
@api.model
def _sheet_create(self, values):
return self.with_context(sheet_create=True).create(values)
def write(self, values):
self._check_state_on_write(values)
res = super().write(values)
if self._timesheet_should_compute_sheet(values):
self._compute_sheet()
return res
def unlink(self):
self._check_state()
return super().unlink()
def _check_state_on_write(self, values):
"""Hook for extensions"""
if self._timesheet_should_check_write(values):
self._check_state()
@api.model
def _timesheet_should_check_write(self, values):
"""Hook for extensions"""
return bool(set(self._get_timesheet_protected_fields()) & set(values.keys()))
@api.model
def _timesheet_should_compute_sheet(self, values):
"""Hook for extensions"""
return any(f in self._get_sheet_affecting_fields() for f in values)
@api.model
def _get_timesheet_protected_fields(self):
"""Hook for extensions"""
return [
"name",
"date",
"unit_amount",
"user_id",
"employee_id",
"department_id",
"company_id",
"task_id",
"project_id",
"sheet_id",
]
@api.model
def _get_sheet_affecting_fields(self):
"""Hook for extensions"""
return ["date", "employee_id", "project_id", "company_id"]
def _check_state(self):
if self.env.context.get("skip_check_state"):
return
for line in self.exists().filtered("sheet_id"):
if line.sheet_id.state not in ["new", "draft"]:
raise UserError(
_(
"You cannot modify an entry in a confirmed timesheet sheet"
": %(names)s",
names=line.sheet_id.complete_name,
)
)
def merge_timesheets(self):
unit_amount = sum(t.unit_amount for t in self)
amount = sum(t.amount for t in self)
self[0].write({"unit_amount": unit_amount, "amount": amount})
self[1:].unlink()
return self[0]
def _check_can_update_timesheet(self):
return super()._check_can_update_timesheet() or not self.filtered("sheet_id")

View file

@ -0,0 +1,56 @@
# Copyright 2018 ForgeFlow, S.L.
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
class HrDepartment(models.Model):
_inherit = "hr.department"
timesheet_sheet_to_approve_count = fields.Integer(
compute="_compute_timesheet_to_approve", string="Timesheet Sheets to Approve"
)
def _compute_timesheet_to_approve(self):
timesheet_data = self.env["hr_timesheet.sheet"].read_group(
[("department_id", "in", self.ids), ("state", "=", "confirm")],
["department_id"],
["department_id"],
)
result = {
data["department_id"][0]: data["department_id_count"]
for data in timesheet_data
}
for department in self:
department.timesheet_sheet_to_approve_count = result.get(department.id, 0)
@api.constrains("company_id")
def _check_company_id(self):
for rec in self.sudo().filtered("company_id"):
for field in [
rec.env["hr_timesheet.sheet"].search(
[
("department_id", "=", rec.id),
("company_id", "!=", rec.company_id.id),
("company_id", "!=", False),
],
limit=1,
)
]:
if (
rec.company_id
and field.company_id
and rec.company_id != field.company_id
):
raise ValidationError(
_(
"You cannot change the company, "
"as this %(rec_name)s (%(rec_display_name)s) "
"is assigned to %(current_name)s (%(current_display_name)s).",
rec_name=rec._name,
rec_display_name=rec.display_name,
current_name=field._name,
current_display_name=field.display_name,
)
)

View file

@ -0,0 +1,56 @@
# Copyright 2018 ForgeFlow, S.L.
# Copyright 2019 Brainbean Apps (https://brainbeanapps.com)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
class HrEmployee(models.Model):
_inherit = "hr.employee"
timesheet_sheet_ids = fields.One2many(
comodel_name="hr_timesheet.sheet",
inverse_name="employee_id",
string="Timesheet Sheets",
)
timesheet_sheet_count = fields.Integer(
compute="_compute_timesheet_sheet_count", string="Timesheet Sheets Count"
)
def _compute_timesheet_sheet_count(self):
Sheet = self.env["hr_timesheet.sheet"]
for employee in self:
employee.timesheet_sheet_count = Sheet.search_count(
[("employee_id", "=", employee.id)]
)
@api.constrains("company_id")
def _check_company_id(self):
for rec in self.sudo().filtered("company_id"):
for field in [
rec.env["hr_timesheet.sheet"].search(
[
("employee_id", "=", rec.id),
("company_id", "!=", rec.company_id.id),
("company_id", "!=", False),
],
limit=1,
)
]:
if (
rec.company_id
and field.company_id
and rec.company_id != field.company_id
):
raise ValidationError(
_(
"You cannot change the company, "
"as this %(rec_name)s (%(rec_display_name)s) "
"is assigned to %(current_name)s (%(current_display_name)s).",
rec_name=rec._name,
rec_display_name=rec.display_name,
current_name=field._name,
current_display_name=field.display_name,
)
)

View file

@ -0,0 +1,922 @@
# Copyright 2018-2020 ForgeFlow, S.L.
# Copyright 2018-2020 Brainbean Apps (https://brainbeanapps.com)
# Copyright 2018-2019 Onestein (<https://www.onestein.eu>)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
import logging
import re
from collections import namedtuple
from datetime import datetime, time
import babel.dates
from dateutil.relativedelta import SU, relativedelta
from odoo import SUPERUSER_ID, _, api, fields, models
from odoo.exceptions import UserError, ValidationError
_logger = logging.getLogger(__name__)
empty_name = "/"
class Sheet(models.Model):
_name = "hr_timesheet.sheet"
_description = "Timesheet Sheet"
_inherit = ["mail.thread", "mail.activity.mixin", "portal.mixin"]
_table = "hr_timesheet_sheet"
_order = "id desc"
_rec_name = "complete_name"
def _default_date_start(self):
return self._get_period_start(
self.env.user.company_id, fields.Date.context_today(self)
)
def _default_date_end(self):
return self._get_period_end(
self.env.user.company_id, fields.Date.context_today(self)
)
def _selection_review_policy(self):
ResCompany = self.env["res.company"]
return ResCompany._fields["timesheet_sheet_review_policy"].selection
def _default_review_policy(self):
company = self.env.company
return company.timesheet_sheet_review_policy
def _default_employee(self):
company = self.env.company
return self.env["hr.employee"].search(
[("user_id", "=", self.env.uid), ("company_id", "in", [company.id, False])],
limit=1,
order="company_id ASC",
)
def _default_department_id(self):
return self._default_employee().department_id
name = fields.Char(compute="_compute_name")
employee_id = fields.Many2one(
comodel_name="hr.employee",
string="Employee",
default=lambda self: self._default_employee(),
required=True,
readonly=True,
states={"new": [("readonly", False)]},
)
user_id = fields.Many2one(
comodel_name="res.users",
related="employee_id.user_id",
string="User",
store=True,
readonly=True,
)
date_start = fields.Date(
string="Date From",
default=lambda self: self._default_date_start(),
required=True,
index=True,
readonly=True,
states={"new": [("readonly", False)]},
)
date_end = fields.Date(
string="Date To",
default=lambda self: self._default_date_end(),
required=True,
index=True,
readonly=True,
states={"new": [("readonly", False)]},
)
timesheet_ids = fields.One2many(
comodel_name="account.analytic.line",
inverse_name="sheet_id",
string="Timesheets",
readonly=True,
states={"new": [("readonly", False)], "draft": [("readonly", False)]},
)
line_ids = fields.One2many(
comodel_name="hr_timesheet.sheet.line",
compute="_compute_line_ids",
string="Timesheet Sheet Lines",
readonly=True,
states={"new": [("readonly", False)], "draft": [("readonly", False)]},
)
new_line_ids = fields.One2many(
comodel_name="hr_timesheet.sheet.new.analytic.line",
inverse_name="sheet_id",
string="Temporary Timesheets",
readonly=True,
states={"new": [("readonly", False)], "draft": [("readonly", False)]},
)
state = fields.Selection(
[
("new", "New"),
("draft", "Open"),
("confirm", "Waiting Review"),
("done", "Approved"),
],
default="new",
tracking=True,
string="Status",
required=True,
readonly=True,
index=True,
)
company_id = fields.Many2one(
comodel_name="res.company",
string="Company",
default=lambda self: self.env.company,
required=True,
readonly=True,
)
review_policy = fields.Selection(
selection=lambda self: self._selection_review_policy(),
default=lambda self: self._default_review_policy(),
required=True,
readonly=True,
)
department_id = fields.Many2one(
comodel_name="hr.department",
string="Department",
default=lambda self: self._default_department_id(),
readonly=True,
states={"new": [("readonly", False)]},
)
reviewer_id = fields.Many2one(
comodel_name="hr.employee", string="Reviewer", readonly=True, tracking=True
)
add_line_project_id = fields.Many2one(
comodel_name="project.project",
string="Select Project",
domain="[('company_id', '=', company_id), ('allow_timesheets', '=', True)]",
help="If selected, the associated project is added "
"to the timesheet sheet when clicked the button.",
)
add_line_task_id = fields.Many2one(
comodel_name="project.task",
string="Select Task",
domain="[('id', 'in', available_task_ids)]",
help="If selected, the associated task is added "
"to the timesheet sheet when clicked the button.",
)
available_task_ids = fields.Many2many(
comodel_name="project.task",
string="Available Tasks",
compute="_compute_available_task_ids",
)
total_time = fields.Float(compute="_compute_total_time", store=True)
can_review = fields.Boolean(
compute="_compute_can_review", search="_search_can_review"
)
complete_name = fields.Char(compute="_compute_complete_name")
@api.depends("date_start", "date_end")
def _compute_name(self):
locale = self.env.context.get("lang") or self.env.user.lang or "en_US"
for sheet in self:
if sheet.date_start == sheet.date_end:
sheet.name = babel.dates.format_skeleton(
skeleton="MMMEd",
datetime=datetime.combine(sheet.date_start, time.min),
locale=locale,
)
continue
period_start = sheet.date_start.strftime("%V, %Y")
period_end = sheet.date_end.strftime("%V, %Y")
if sheet.date_end <= sheet.date_start + relativedelta(weekday=SU):
sheet.name = _("Week %(end)s", end=period_end)
else:
sheet.name = _(
"Weeks %(start)s - %(end)s", start=period_start, end=period_end
)
@api.depends("timesheet_ids.unit_amount")
def _compute_total_time(self):
for sheet in self:
sheet.total_time = sum(sheet.mapped("timesheet_ids.unit_amount"))
@api.depends("review_policy")
def _compute_can_review(self):
for sheet in self:
sheet.can_review = self.env.user in sheet._get_possible_reviewers()
@api.model
def _search_can_review(self, operator, value):
def check_in(users):
return self.env.user in users
def check_not_in(users):
return self.env.user not in users
if (operator == "=" and value) or (operator in ["<>", "!="] and not value):
check = check_in
else:
check = check_not_in
sheets = self.search([]).filtered(
lambda sheet: check(sheet._get_possible_reviewers())
)
return [("id", "in", sheets.ids)]
@api.depends("name", "employee_id")
def _compute_complete_name(self):
for sheet in self:
complete_name = sheet.name
complete_name_components = sheet._get_complete_name_components()
if complete_name_components:
complete_name = "{} ({})".format(
complete_name,
", ".join(complete_name_components),
)
sheet.complete_name = complete_name
@api.constrains("date_start", "date_end")
def _check_start_end_dates(self):
for sheet in self:
if sheet.date_start > sheet.date_end:
raise ValidationError(
_("The start date cannot be later than the end date.")
)
def _get_complete_name_components(self):
"""Hook for extensions"""
self.ensure_one()
return [self.employee_id.name_get()[0][1]]
def _get_overlapping_sheet_domain(self):
"""Hook for extensions"""
self.ensure_one()
return [
("id", "!=", self.id),
("date_start", "<=", self.date_end),
("date_end", ">=", self.date_start),
("employee_id", "=", self.employee_id.id),
("company_id", "=", self._get_timesheet_sheet_company().id),
]
@api.constrains(
"date_start", "date_end", "company_id", "employee_id", "review_policy"
)
def _check_overlapping_sheets(self):
for sheet in self:
overlapping_sheets = self.search(sheet._get_overlapping_sheet_domain())
if overlapping_sheets:
raise ValidationError(
_(
"You cannot have 2 or more sheets that overlap!\n"
'Please use the menu "Timesheet Sheet" '
"to avoid this problem.\nConflicting sheets:\n - %(names)s",
names=(
"\n - ".join(overlapping_sheets.mapped("complete_name")),
),
)
)
@api.constrains("company_id", "employee_id")
def _check_company_id_employee_id(self):
for rec in self.sudo():
if (
rec.company_id
and rec.employee_id.company_id
and rec.company_id != rec.employee_id.company_id
):
raise ValidationError(
_(
"The Company in the Timesheet Sheet and in "
"the Employee must be the same."
)
)
@api.constrains("company_id", "department_id")
def _check_company_id_department_id(self):
for rec in self.sudo():
if (
rec.company_id
and rec.department_id.company_id
and rec.company_id != rec.department_id.company_id
):
raise ValidationError(
_(
"The Company in the Timesheet Sheet and in "
"the Department must be the same."
)
)
@api.constrains("company_id", "add_line_project_id")
def _check_company_id_add_line_project_id(self):
for rec in self.sudo():
if (
rec.company_id
and rec.add_line_project_id.company_id
and rec.company_id != rec.add_line_project_id.company_id
):
raise ValidationError(
_(
"The Company in the Timesheet Sheet and in "
"the Project must be the same."
)
)
@api.constrains("company_id", "add_line_task_id")
def _check_company_id_add_line_task_id(self):
for rec in self.sudo():
if (
rec.company_id
and rec.add_line_task_id.company_id
and rec.company_id != rec.add_line_task_id.company_id
):
raise ValidationError(
_(
"The Company in the Timesheet Sheet and in "
"the Task must be the same."
)
)
def _get_possible_reviewers(self):
self.ensure_one()
res = self.env["res.users"].browse(SUPERUSER_ID)
if self.review_policy == "hr":
res |= self.env.ref("hr.group_hr_user").users
elif self.review_policy == "hr_manager":
res |= self.env.ref("hr.group_hr_manager").users
elif self.review_policy == "timesheet_manager":
res |= self.env.ref("hr_timesheet.group_hr_timesheet_approver").users
return res
def _get_timesheet_sheet_company(self):
self.ensure_one()
employee = self.employee_id
company = employee.company_id or employee.department_id.company_id
if not company:
company = employee.user_id.company_id
return company
@api.onchange("employee_id")
def _onchange_employee_id(self):
if self.employee_id:
company = self._get_timesheet_sheet_company()
self.company_id = company
self.review_policy = company.timesheet_sheet_review_policy
self.department_id = self.employee_id.department_id
def _get_timesheet_sheet_lines_domain(self):
self.ensure_one()
return [
("date", "<=", self.date_end),
("date", ">=", self.date_start),
("employee_id", "=", self.employee_id.id),
("company_id", "=", self._get_timesheet_sheet_company().id),
("project_id", "!=", False),
]
@api.depends("date_start", "date_end")
def _compute_line_ids(self):
SheetLine = self.env["hr_timesheet.sheet.line"]
for sheet in self:
if not all([sheet.date_start, sheet.date_end]):
continue
matrix = sheet._get_data_matrix()
vals_list = []
for key in sorted(matrix, key=lambda key: sheet._get_matrix_sortby(key)):
vals_list.append(sheet._get_default_sheet_line(matrix, key))
if sheet.state in ["new", "draft"] and self.env.context.get(
"hr_timesheet_sheet_clean_timesheets", True
):
sheet.clean_timesheets(matrix[key])
sheet.line_ids = [(6, 0, SheetLine.create(vals_list).ids)]
@api.model
def _matrix_key_attributes(self):
"""Hook for extensions"""
return ["date", "project_id", "task_id"]
@api.model
def _matrix_key(self):
return namedtuple("MatrixKey", self._matrix_key_attributes())
@api.model
def _get_matrix_key_values_for_line(self, aal):
"""Hook for extensions"""
return {"date": aal.date, "project_id": aal.project_id, "task_id": aal.task_id}
@api.model
def _get_matrix_sortby(self, key):
res = []
for attribute in key:
if hasattr(attribute, "name_get"):
name = attribute.name_get()
value = name[0][1] if name else ""
else:
value = attribute
res.append(value)
return res
def _get_data_matrix(self):
self.ensure_one()
MatrixKey = self._matrix_key()
matrix = {}
empty_line = self.env["account.analytic.line"]
for line in self.timesheet_ids:
key = MatrixKey(**self._get_matrix_key_values_for_line(line))
if key not in matrix:
matrix[key] = empty_line
matrix[key] += line
for date in self._get_dates():
for key in matrix.copy():
key = MatrixKey(**{**key._asdict(), "date": date})
if key not in matrix:
matrix[key] = empty_line
return matrix
def _compute_timesheet_ids(self):
AccountAnalyticLines = self.env["account.analytic.line"]
for sheet in self:
domain = sheet._get_timesheet_sheet_lines_domain()
timesheets = AccountAnalyticLines.search(domain)
sheet.link_timesheets_to_sheet(timesheets)
sheet.timesheet_ids = [(6, 0, timesheets.ids)]
@api.onchange("date_start", "date_end", "employee_id")
def _onchange_scope(self):
self._compute_timesheet_ids()
@api.onchange("date_start", "date_end")
def _onchange_dates(self):
if self.date_start > self.date_end:
self.date_end = self.date_start
@api.onchange("timesheet_ids")
def _onchange_timesheets(self):
self._compute_line_ids()
@api.depends(
"add_line_project_id", "company_id", "timesheet_ids", "timesheet_ids.task_id"
)
def _compute_available_task_ids(self):
project_task_obj = self.env["project.task"]
for rec in self:
if rec.add_line_project_id:
rec.available_task_ids = project_task_obj.search(
[
("project_id", "=", rec.add_line_project_id.id),
("company_id", "=", rec.company_id.id),
("id", "not in", rec.timesheet_ids.mapped("task_id").ids),
]
).ids
else:
rec.available_task_ids = []
@api.model
def _check_employee_user_link(self, vals):
if vals.get("employee_id"):
employee = self.env["hr.employee"].sudo().browse(vals["employee_id"])
if not employee.user_id:
raise UserError(
_(
"In order to create a sheet for this employee, you must"
" link him/her to an user: %s"
)
% (employee.name,)
)
return employee.user_id.id
return False
def copy(self, default=None):
if not self.env.context.get("allow_copy_timesheet"):
raise UserError(_("You cannot duplicate a sheet."))
return super().copy(default=default)
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
self._check_employee_user_link(vals)
res = super().create(vals_list)
res.write({"state": "draft"})
return res
def _sheet_write(self, field, recs):
self.with_context(sheet_write=True).write({field: [(6, 0, recs.ids)]})
def write(self, vals):
self._check_employee_user_link(vals)
res = super().write(vals)
for rec in self:
if rec.state == "draft" and not self.env.context.get("sheet_write"):
rec._update_analytic_lines_from_new_lines(vals)
if "add_line_project_id" not in vals:
rec.delete_empty_lines(True)
return res
def unlink(self):
for sheet in self:
if sheet.state in ("confirm", "done"):
raise UserError(
_(
"You cannot delete a timesheet sheet which is already"
" submitted or confirmed: %s"
)
% (sheet.complete_name,)
)
return super().unlink()
def onchange(self, values, field_name, field_onchange):
"""
Pass a flag for _compute_line_ids not to clean timesheet lines to be (kind of)
idempotent during onchange
"""
return super(
Sheet, self.with_context(hr_timesheet_sheet_clean_timesheets=False)
).onchange(values, field_name, field_onchange)
def _get_informables(self):
"""Hook for extensions"""
self.ensure_one()
return self.employee_id.parent_id.user_id.partner_id
def _get_subscribers(self):
"""Hook for extensions"""
self.ensure_one()
subscribers = self._get_possible_reviewers().mapped("partner_id")
subscribers |= self._get_informables()
return subscribers
def _timesheet_subscribe_users(self):
for sheet in self.sudo():
subscribers = sheet._get_subscribers()
if subscribers:
sheet.message_subscribe(partner_ids=subscribers.ids)
def action_timesheet_draft(self):
if self.filtered(lambda sheet: sheet.state != "done"):
raise UserError(_("Cannot revert to draft a non-approved sheet."))
self._check_can_review()
self.write({"state": "draft", "reviewer_id": False})
def action_timesheet_confirm(self):
self._timesheet_subscribe_users()
self.reset_add_line()
self.write({"state": "confirm"})
def action_timesheet_done(self):
if self.filtered(lambda sheet: sheet.state != "confirm"):
raise UserError(_("Cannot approve a non-submitted sheet."))
self._check_can_review()
self.write({"state": "done", "reviewer_id": self._get_current_reviewer().id})
def action_timesheet_refuse(self):
if self.filtered(lambda sheet: sheet.state != "confirm"):
raise UserError(_("Cannot reject a non-submitted sheet."))
self._check_can_review()
self.write({"state": "draft", "reviewer_id": False})
@api.model
def _get_current_reviewer(self):
reviewer = self.env["hr.employee"].search(
[("user_id", "=", self.env.uid)], limit=1
)
if not reviewer:
raise UserError(
_(
"In order to review a timesheet sheet, your user needs to be"
" linked to an employee."
)
)
return reviewer
def _check_can_review(self):
if self.filtered(lambda x: not x.can_review and x.review_policy == "hr"):
raise UserError(_("Only a HR Officer or Manager can review the sheet."))
def button_add_line(self):
for rec in self:
if rec.state in ["new", "draft"]:
rec.add_line()
rec.reset_add_line()
def reset_add_line(self):
self.write({"add_line_project_id": False, "add_line_task_id": False})
def _get_date_name(self, date):
name = babel.dates.format_skeleton(
skeleton="MMMEd",
datetime=datetime.combine(date, time.min),
locale=(self.env.context.get("lang") or self.env.user.lang or "en_US"),
)
name = re.sub(r"(\s*[^\w\d\s])\s+", r"\1\n", name)
name = re.sub(r"([\w\d])\s([\w\d])", "\\1\u00A0\\2", name)
return name
def _get_dates(self):
start = self.date_start
end = self.date_end
if end < start:
return []
dates = [start]
while start != end:
start += relativedelta(days=1)
dates.append(start)
return dates
def _get_line_name(self, project_id, task_id=None, **kwargs):
self.ensure_one()
if task_id:
return "{} - {}".format(
project_id.name_get()[0][1], task_id.name_get()[0][1]
)
return project_id.name_get()[0][1]
def _get_new_line_unique_id(self):
"""Hook for extensions"""
self.ensure_one()
return {
"project_id": self.add_line_project_id,
"task_id": self.add_line_task_id,
}
def _get_default_sheet_line(self, matrix, key):
self.ensure_one()
values = {
"value_x": self._get_date_name(key.date),
"value_y": self._get_line_name(**key._asdict()),
"date": key.date,
"project_id": key.project_id.id,
"task_id": key.task_id.id,
"unit_amount": sum(t.unit_amount for t in matrix[key]),
"employee_id": self.employee_id.id,
"company_id": self.company_id.id,
}
if self.id:
values.update({"sheet_id": self.id})
return values
@api.model
def _prepare_empty_analytic_line(self):
return {
"name": empty_name,
"employee_id": self.employee_id.id,
"date": self.date_start,
"project_id": self.add_line_project_id.id,
"task_id": self.add_line_task_id.id,
"sheet_id": self.id,
"unit_amount": 0.0,
"company_id": self.company_id.id,
}
def add_line(self):
if not self.add_line_project_id:
return
values = self._prepare_empty_analytic_line()
new_line_unique_id = self._get_new_line_unique_id()
existing_unique_ids = list(
{frozenset(line.get_unique_id().items()) for line in self.line_ids}
)
if existing_unique_ids:
self.delete_empty_lines(False)
if frozenset(new_line_unique_id.items()) not in existing_unique_ids:
new_line = self.env["account.analytic.line"]._sheet_create(values)
self.write({"timesheet_ids": [(4, new_line.id)]})
def link_timesheets_to_sheet(self, timesheets):
self.ensure_one()
if self.id and self.state in ["new", "draft"]:
for aal in timesheets.filtered(lambda a: not a.sheet_id):
aal.write({"sheet_id": self.id})
def clean_timesheets(self, timesheets):
repeated = timesheets.filtered(
lambda t: t.name == empty_name and not t.timesheet_invoice_id
)
if len(repeated) > 1 and self.id:
return repeated.merge_timesheets()
return timesheets
def _is_add_line(self, row):
"""Hook for extensions"""
self.ensure_one()
return (
self.add_line_project_id == row.project_id
and self.add_line_task_id == row.task_id
)
@api.model
def _is_line_of_row(self, aal, row):
"""Hook for extensions"""
return (
aal.project_id.id == row.project_id.id and aal.task_id.id == row.task_id.id
)
def delete_empty_lines(self, delete_empty_rows=False):
self.ensure_one()
for name in list(set(self.line_ids.mapped("value_y"))):
rows = self.line_ids.filtered(lambda l: l.value_y == name)
if not rows:
continue
row = fields.first(rows)
if delete_empty_rows and self._is_add_line(row):
check = any([line.unit_amount for line in rows])
else:
check = not all([line.unit_amount for line in rows])
if not check:
continue
row_lines = self.timesheet_ids.filtered(
lambda aal: self._is_line_of_row(aal, row)
)
row_lines.filtered(
lambda t: t.name == empty_name
and not t.unit_amount
and not t.timesheet_invoice_id
).unlink()
if self.timesheet_ids != self.timesheet_ids.exists():
self._sheet_write("timesheet_ids", self.timesheet_ids.exists())
def _update_analytic_lines_from_new_lines(self, vals):
self.ensure_one()
new_line_ids_list = []
for line in vals.get("line_ids", []):
# Every time we change a value in the grid a new line in line_ids
# is created with the proposed changes, even though the line_ids
# is a computed field. We capture the value of 'new_line_ids'
# in the proposed dict before it disappears.
# This field holds the ids of the transient records
# of model 'hr_timesheet.sheet.new.analytic.line'.
if line[0] == 1 and line[2] and line[2].get("new_line_id"):
new_line_ids_list += [line[2].get("new_line_id")]
for new_line in self.new_line_ids.exists():
if new_line.id in new_line_ids_list:
new_line._update_analytic_lines()
self.new_line_ids.exists().unlink()
self._sheet_write("new_line_ids", self.new_line_ids.exists())
@api.model
def _prepare_new_line(self, line):
"""Hook for extensions"""
return {
"sheet_id": line.sheet_id.id,
"date": line.date,
"project_id": line.project_id.id,
"task_id": line.task_id.id,
"unit_amount": line.unit_amount,
"company_id": line.company_id.id,
"employee_id": line.employee_id.id,
}
def _is_compatible_new_line(self, line_a, line_b):
"""Hook for extensions"""
self.ensure_one()
return (
line_a.project_id.id == line_b.project_id.id
and line_a.task_id.id == line_b.task_id.id
and line_a.date == line_b.date
)
def add_new_line(self, line):
self.ensure_one()
new_line_model = self.env["hr_timesheet.sheet.new.analytic.line"]
new_line = self.new_line_ids.filtered(
lambda l: self._is_compatible_new_line(l, line)
)
if new_line:
new_line.write({"unit_amount": line.unit_amount})
else:
vals = self._prepare_new_line(line)
new_line = new_line_model.create(vals)
self._sheet_write("new_line_ids", self.new_line_ids | new_line)
line.new_line_id = new_line.id
@api.model
def _get_period_start(self, company, date):
r = company and company.sheet_range or "WEEKLY"
if r == "WEEKLY":
if company.timesheet_week_start:
delta = relativedelta(weekday=int(company.timesheet_week_start), days=6)
else:
delta = relativedelta(days=date.weekday())
return date - delta
elif r == "MONTHLY":
return date + relativedelta(day=1)
return date
@api.model
def _get_period_end(self, company, date):
r = company and company.sheet_range or "WEEKLY"
if r == "WEEKLY":
if company.timesheet_week_start:
delta = relativedelta(
weekday=(int(company.timesheet_week_start) + 6) % 7
)
else:
delta = relativedelta(days=6 - date.weekday())
return date + delta
elif r == "MONTHLY":
return date + relativedelta(months=1, day=1, days=-1)
return date
# ------------------------------------------------
# OpenChatter methods and notifications
# ------------------------------------------------
def _track_subtype(self, init_values):
if self:
record = self[0]
if "state" in init_values and record.state == "confirm":
return self.env.ref("hr_timesheet_sheet.mt_timesheet_confirmed")
elif "state" in init_values and record.state == "done":
return self.env.ref("hr_timesheet_sheet.mt_timesheet_approved")
return super()._track_subtype(init_values)
class AbstractSheetLine(models.AbstractModel):
_name = "hr_timesheet.sheet.line.abstract"
_description = "Abstract Timesheet Sheet Line"
sheet_id = fields.Many2one(comodel_name="hr_timesheet.sheet", ondelete="cascade")
date = fields.Date()
project_id = fields.Many2one(comodel_name="project.project", string="Project")
task_id = fields.Many2one(comodel_name="project.task", string="Task")
unit_amount = fields.Float(string="Quantity", default=0.0)
company_id = fields.Many2one(comodel_name="res.company", string="Company")
employee_id = fields.Many2one(comodel_name="hr.employee", string="Employee")
def get_unique_id(self):
"""Hook for extensions"""
self.ensure_one()
return {"project_id": self.project_id, "task_id": self.task_id}
class SheetLine(models.TransientModel):
_name = "hr_timesheet.sheet.line"
_inherit = "hr_timesheet.sheet.line.abstract"
_description = "Timesheet Sheet Line"
value_x = fields.Char(string="Date Name")
value_y = fields.Char(string="Project Name")
new_line_id = fields.Integer(default=0)
@api.onchange("unit_amount")
def onchange_unit_amount(self):
"""This method is called when filling a cell of the matrix."""
self.ensure_one()
sheet = self._get_sheet()
if not sheet:
return {
"warning": {
"title": _("Warning"),
"message": _("Save the Timesheet Sheet first."),
}
}
sheet.add_new_line(self)
@api.model
def _get_sheet(self):
sheet = (self._origin or self).sheet_id
if not sheet:
model = self.env.context.get("params", {}).get("model", "")
obj_id = self.env.context.get("params", {}).get("id")
if model == "hr_timesheet.sheet" and isinstance(obj_id, int):
sheet = self.env["hr_timesheet.sheet"].browse(obj_id)
return sheet
class SheetNewAnalyticLine(models.TransientModel):
_name = "hr_timesheet.sheet.new.analytic.line"
_inherit = "hr_timesheet.sheet.line.abstract"
_description = "Timesheet Sheet New Analytic Line"
@api.model
def _is_similar_analytic_line(self, aal):
"""Hook for extensions"""
return (
aal.date == self.date
and aal.project_id.id == self.project_id.id
and aal.task_id.id == self.task_id.id
)
@api.model
def _update_analytic_lines(self):
sheet = self.sheet_id
timesheets = sheet.timesheet_ids.filtered(
lambda aal: self._is_similar_analytic_line(aal)
)
new_ts = timesheets.filtered(lambda t: t.name == empty_name)
amount = sum(t.unit_amount for t in timesheets)
diff_amount = self.unit_amount - amount
if len(new_ts) > 1:
new_ts = new_ts.merge_timesheets()
sheet._sheet_write("timesheet_ids", sheet.timesheet_ids.exists())
if not diff_amount:
return
if new_ts:
unit_amount = new_ts.unit_amount + diff_amount
if unit_amount:
new_ts.write({"unit_amount": unit_amount})
else:
new_ts.unlink()
sheet._sheet_write("timesheet_ids", sheet.timesheet_ids.exists())
else:
new_ts_values = sheet._prepare_new_line(self)
new_ts_values.update({"name": empty_name, "unit_amount": diff_amount})
self.env["account.analytic.line"]._sheet_create(new_ts_values)

View file

@ -0,0 +1,39 @@
# Copyright 2018 ForgeFlow, S.L.
# Copyright 2019 Brainbean Apps (https://brainbeanapps.com)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import fields, models
_WEEKDAYS = [
("0", "Monday"),
("1", "Tuesday"),
("2", "Wednesday"),
("3", "Thursday"),
("4", "Friday"),
("5", "Saturday"),
("6", "Sunday"),
]
class ResCompany(models.Model):
_inherit = "res.company"
sheet_range = fields.Selection(
[("MONTHLY", "Month"), ("WEEKLY", "Week"), ("DAILY", "Day")],
string="Timesheet Sheet Range",
default="WEEKLY",
help="The range of your Timesheet Sheet.",
)
timesheet_week_start = fields.Selection(
selection=_WEEKDAYS, string="Week start day", default="0"
)
timesheet_sheet_review_policy = fields.Selection(
selection=[
("hr", "By HR Officers"),
("hr_manager", "By HR Managers"),
("timesheet_manager", "By Timesheets Managers"),
],
default="hr",
help="How Timesheet Sheets review is performed.",
)

View file

@ -0,0 +1,27 @@
# Copyright 2018 ForgeFlow, S.L.
# Copyright 2019 Brainbean Apps (https://brainbeanapps.com)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import fields, models
class ResConfig(models.TransientModel):
_inherit = "res.config.settings"
sheet_range = fields.Selection(
related="company_id.sheet_range",
string="Timesheet Sheet Range",
help="The range of your Timesheet Sheet.",
readonly=False,
)
timesheet_week_start = fields.Selection(
related="company_id.timesheet_week_start",
string="Week Start Day",
help="Starting day for Timesheet Sheets.",
readonly=False,
)
timesheet_sheet_review_policy = fields.Selection(
related="company_id.timesheet_sheet_review_policy", readonly=False
)

Some files were not shown because too many files have changed in this diff Show more