Начальное наполнение

This commit is contained in:
parent 24b2b15006
commit 5c5842780d
150 changed files with 121545 additions and 0 deletions

50
__init__.py Normal file
View File

@ -0,0 +1,50 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import controllers
from . import models
from . import report
from . import wizard
from . import populate
from odoo import fields, _
from odoo.addons.project import _check_exists_collaborators_for_project_sharing
def create_internal_project(env):
# allow_timesheets is set by default, but erased for existing projects at
# installation, as there is no analytic account for them.
env['project.project'].search([]).write({'allow_timesheets': True})
admin = env.ref('base.user_admin', raise_if_not_found=False)
if not admin:
return
project_ids = env['res.company'].search([])._create_internal_project_task()
env['account.analytic.line'].create([{
'name': _("Analysis"),
'user_id': admin.id,
'date': fields.datetime.today(),
'unit_amount': 0,
'project_id': task.project_id.id,
'task_id': task.id,
} for task in project_ids.task_ids.filtered(lambda t: t.company_id in admin.employee_ids.company_id)])
_check_exists_collaborators_for_project_sharing(env)
def _uninstall_hook(env):
def update_action_window(xmlid):
act_window = env.ref(xmlid, raise_if_not_found=False)
if act_window and act_window.domain and 'is_internal_project' in act_window.domain:
act_window.domain = []
update_action_window('project.open_view_project_all')
update_action_window('project.open_view_project_all_group_stage')
# archive the internal projects
project_ids = env['res.company'].search([('internal_project_id', '!=', False)]).mapped('internal_project_id')
if project_ids:
project_ids.write({'active': False})
env['ir.model.data'].search([('name', 'ilike', 'internal_project_default_stage')]).unlink()

66
__manifest__.py Normal file
View File

@ -0,0 +1,66 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
{
'name': 'Task Logs',
'version': '1.0',
'category': 'Services/Timesheets',
'sequence': 23,
'summary': 'Track employee time on tasks',
'description': """
This module implements a timesheet system.
==========================================
Each employee can encode and track their time spent on the different projects.
Lots of reporting on time and employee tracking are provided.
It is completely integrated with the cost accounting module. It allows you to set
up a management by affair.
""",
'website': 'https://www.odoo.com/app/timesheet',
'depends': ['hr', 'hr_hourly_cost', 'analytic', 'project', 'uom'],
'data': [
'security/hr_timesheet_security.xml',
'security/ir.model.access.csv',
'security/ir.model.access.xml',
'data/digest_data.xml',
'views/hr_timesheet_views.xml',
'views/res_config_settings_views.xml',
'views/project_project_views.xml',
'views/project_task_views.xml',
'views/project_task_portal_templates.xml',
'views/hr_timesheet_portal_templates.xml',
'report/hr_timesheet_report_view.xml',
'report/project_report_view.xml',
'report/report_timesheet_templates.xml',
'views/hr_department_views.xml',
'views/hr_employee_views.xml',
'data/hr_timesheet_data.xml',
'views/project_task_sharing_views.xml',
'views/project_update_views.xml',
'wizard/hr_employee_delete_wizard_views.xml',
'views/hr_timesheet_menus.xml',
],
'demo': [
'data/hr_timesheet_demo.xml',
],
'installable': True,
'post_init_hook': 'create_internal_project',
'uninstall_hook': '_uninstall_hook',
'assets': {
'web.assets_backend': [
'hr_timesheet/static/src/**/*',
],
'web.qunit_suite_tests': [
'hr_timesheet/static/tests/**/*',
],
'project.webclient': [
'hr_timesheet/static/src/services/**/*',
'hr_timesheet/static/src/components/**/*',
'hr_timesheet/static/src/scss/timesheets_task_form.scss'
],
},
'license': 'LGPL-3',
}

5
controllers/__init__.py Normal file
View File

@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import portal
from . import project

185
controllers/portal.py Normal file
View File

@ -0,0 +1,185 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from collections import OrderedDict
from dateutil.relativedelta import relativedelta
from operator import itemgetter
from odoo import fields, http, _
from odoo.http import request
from odoo.tools import date_utils, groupby as groupbyelem
from odoo.osv.expression import AND, OR
from odoo.addons.portal.controllers.portal import CustomerPortal, pager as portal_pager
from odoo.addons.project.controllers.portal import ProjectCustomerPortal
class TimesheetCustomerPortal(CustomerPortal):
def _prepare_home_portal_values(self, counters):
values = super()._prepare_home_portal_values(counters)
if 'timesheet_count' in counters:
Timesheet = request.env['account.analytic.line']
domain = Timesheet._timesheet_get_portal_domain()
values['timesheet_count'] = Timesheet.sudo().search_count(domain)
return values
def _get_searchbar_inputs(self):
return {
'all': {'input': 'all', 'label': _('Search in All')},
'employee': {'input': 'employee', 'label': _('Search in Employee')},
'project': {'input': 'project', 'label': _('Search in Project')},
'task': {'input': 'task', 'label': _('Search in Task')},
'name': {'input': 'name', 'label': _('Search in Description')},
}
def _task_get_searchbar_sortings(self, milestones_allowed, project=False):
values = super()._task_get_searchbar_sortings(milestones_allowed, project)
values['progress'] = {'label': _('Progress'), 'order': 'progress asc', 'sequence': 10}
return values
def _get_searchbar_groupby(self):
return {
'none': {'input': 'none', 'label': _('None')},
'project': {'input': 'project', 'label': _('Project')},
'task': {'input': 'task', 'label': _('Task')},
'date': {'input': 'date', 'label': _('Date')},
'employee': {'input': 'employee', 'label': _('Employee')}
}
def _get_search_domain(self, search_in, search):
search_domain = []
if search_in in ('project', 'all'):
search_domain = OR([search_domain, [('project_id', 'ilike', search)]])
if search_in in ('name', 'all'):
search_domain = OR([search_domain, [('name', 'ilike', search)]])
if search_in in ('employee', 'all'):
search_domain = OR([search_domain, [('employee_id', 'ilike', search)]])
if search_in in ('task', 'all'):
search_domain = OR([search_domain, [('task_id', 'ilike', search)]])
return search_domain
def _get_groupby_mapping(self):
return {
'project': 'project_id',
'task': 'task_id',
'employee': 'employee_id',
'date': 'date'
}
def _get_searchbar_sortings(self):
return {
'date': {'label': _('Newest'), 'order': 'date desc'},
'employee': {'label': _('Employee'), 'order': 'employee_id'},
'project': {'label': _('Project'), 'order': 'project_id'},
'task': {'label': _('Task'), 'order': 'task_id'},
'name': {'label': _('Description'), 'order': 'name'},
}
@http.route(['/my/timesheets', '/my/timesheets/page/<int:page>'], type='http', auth="user", website=True)
def portal_my_timesheets(self, page=1, sortby=None, filterby=None, search=None, search_in='all', groupby='none', **kw):
Timesheet = request.env['account.analytic.line']
domain = Timesheet._timesheet_get_portal_domain()
Timesheet_sudo = Timesheet.sudo()
values = self._prepare_portal_layout_values()
_items_per_page = 100
searchbar_sortings = self._get_searchbar_sortings()
searchbar_inputs = self._get_searchbar_inputs()
searchbar_groupby = self._get_searchbar_groupby()
today = fields.Date.today()
quarter_start, quarter_end = date_utils.get_quarter(today)
last_week = today + relativedelta(weeks=-1)
last_month = today + relativedelta(months=-1)
last_year = today + relativedelta(years=-1)
searchbar_filters = {
'all': {'label': _('All'), 'domain': []},
'today': {'label': _('Today'), 'domain': [("date", "=", today)]},
'week': {'label': _('This week'), 'domain': [('date', '>=', date_utils.start_of(today, "week")), ('date', '<=', date_utils.end_of(today, 'week'))]},
'month': {'label': _('This month'), 'domain': [('date', '>=', date_utils.start_of(today, 'month')), ('date', '<=', date_utils.end_of(today, 'month'))]},
'year': {'label': _('This year'), 'domain': [('date', '>=', date_utils.start_of(today, 'year')), ('date', '<=', date_utils.end_of(today, 'year'))]},
'quarter': {'label': _('This Quarter'), 'domain': [('date', '>=', quarter_start), ('date', '<=', quarter_end)]},
'last_week': {'label': _('Last week'), 'domain': [('date', '>=', date_utils.start_of(last_week, "week")), ('date', '<=', date_utils.end_of(last_week, 'week'))]},
'last_month': {'label': _('Last month'), 'domain': [('date', '>=', date_utils.start_of(last_month, 'month')), ('date', '<=', date_utils.end_of(last_month, 'month'))]},
'last_year': {'label': _('Last year'), 'domain': [('date', '>=', date_utils.start_of(last_year, 'year')), ('date', '<=', date_utils.end_of(last_year, 'year'))]},
}
# default sort by value
if not sortby:
sortby = 'date'
order = searchbar_sortings[sortby]['order']
# default filter by value
if not filterby:
filterby = 'all'
domain = AND([domain, searchbar_filters[filterby]['domain']])
if search and search_in:
domain += self._get_search_domain(search_in, search)
timesheet_count = Timesheet_sudo.search_count(domain)
# pager
pager = portal_pager(
url="/my/timesheets",
url_args={'sortby': sortby, 'search_in': search_in, 'search': search, 'filterby': filterby, 'groupby': groupby},
total=timesheet_count,
page=page,
step=_items_per_page
)
def get_timesheets():
groupby_mapping = self._get_groupby_mapping()
field = groupby_mapping.get(groupby, None)
orderby = '%s, %s' % (field, order) if field else order
timesheets = Timesheet_sudo.search(domain, order=orderby, limit=_items_per_page, offset=pager['offset'])
if field:
if groupby == 'date':
raw_timesheets_group = Timesheet_sudo._read_group(
domain, ['date:day'], ['unit_amount:sum', 'id:recordset']
)
grouped_timesheets = [(records, unit_amount) for __, unit_amount, records in raw_timesheets_group]
else:
time_data = Timesheet_sudo._read_group(domain, [field], ['unit_amount:sum'])
mapped_time = {field.id: unit_amount for field, unit_amount in time_data}
grouped_timesheets = [(Timesheet_sudo.concat(*g), mapped_time[k.id]) for k, g in groupbyelem(timesheets, itemgetter(field))]
return timesheets, grouped_timesheets
grouped_timesheets = [(
timesheets,
sum(Timesheet_sudo.search(domain).mapped('unit_amount'))
)] if timesheets else []
return timesheets, grouped_timesheets
timesheets, grouped_timesheets = get_timesheets()
values.update({
'timesheets': timesheets,
'grouped_timesheets': grouped_timesheets,
'page_name': 'timesheet',
'default_url': '/my/timesheets',
'pager': pager,
'searchbar_sortings': searchbar_sortings,
'search_in': search_in,
'search': search,
'sortby': sortby,
'groupby': groupby,
'searchbar_inputs': searchbar_inputs,
'searchbar_groupby': searchbar_groupby,
'searchbar_filters': OrderedDict(sorted(searchbar_filters.items())),
'filterby': filterby,
'is_uom_day': request.env['account.analytic.line']._is_timesheet_encode_uom_day(),
})
return request.render("hr_timesheet.portal_my_timesheets", values)
class TimesheetProjectCustomerPortal(ProjectCustomerPortal):
def _show_task_report(self, task_sudo, report_type, download):
domain = request.env['account.analytic.line']._timesheet_get_portal_domain()
task_domain = AND([domain, [('task_id', '=', task_sudo.id)]])
timesheets = request.env['account.analytic.line'].sudo().search(task_domain)
return self._show_report(model=timesheets,
report_type=report_type, report_ref='hr_timesheet.timesheet_report_task_timesheets', download=download)

59
controllers/project.py Normal file
View File

@ -0,0 +1,59 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from collections import defaultdict
from odoo.http import request
from odoo.osv import expression
from odoo.addons.project.controllers.portal import CustomerPortal
class ProjectCustomerPortal(CustomerPortal):
def _get_project_sharing_company(self, project):
company = project.company_id
if not company:
timesheet = request.env['account.analytic.line'].sudo().search([('project_id', '=', project.id)], limit=1)
company = timesheet.company_id or request.env.user.company_id
return company
def _prepare_project_sharing_session_info(self, project, task=None):
session_info = super()._prepare_project_sharing_session_info(project, task)
company = request.env['res.company'].sudo().browse(session_info['user_companies']['current_company'])
timesheet_encode_uom = company.timesheet_encode_uom_id
project_time_mode_uom = company.project_time_mode_id
session_info['user_companies']['allowed_companies'][company.id].update(
timesheet_uom_id=timesheet_encode_uom.id,
timesheet_uom_factor=project_time_mode_uom._compute_quantity(
1.0,
timesheet_encode_uom,
round=False
),
)
session_info['uom_ids'] = {
uom.id:
{
'id': uom.id,
'name': uom.name,
'rounding': uom.rounding,
'timesheet_widget': uom.timesheet_widget,
} for uom in [timesheet_encode_uom, project_time_mode_uom]
}
return session_info
def _task_get_page_view_values(self, task, access_token, **kwargs):
values = super(ProjectCustomerPortal, self)._task_get_page_view_values(task, access_token, **kwargs)
domain = request.env['account.analytic.line']._timesheet_get_portal_domain()
task_domain = expression.AND([domain, [('task_id', '=', task.id)]])
subtask_domain = expression.AND([domain, [('task_id', 'in', task.child_ids.ids)]])
timesheets = request.env['account.analytic.line'].sudo().search(task_domain)
subtasks_timesheets = request.env['account.analytic.line'].sudo().search(subtask_domain)
timesheets_by_subtask = defaultdict(lambda: request.env['account.analytic.line'].sudo())
for timesheet in subtasks_timesheets:
timesheets_by_subtask[timesheet.task_id] |= timesheet
values['allow_timesheets'] = task.allow_timesheets
values['timesheets'] = timesheets
values['timesheets_by_subtask'] = timesheets_by_subtask
values['is_uom_day'] = request.env['account.analytic.line']._is_timesheet_encode_uom_day()
return values

17
data/digest_data.xml Normal file
View File

@ -0,0 +1,17 @@
<?xml version='1.0' encoding='utf-8'?>
<odoo>
<data>
<record id="digest_tip_hr_timesheet_0" model="digest.tip">
<field name="name">Tip: Record your Timesheets faster</field>
<field name="sequence">2200</field>
<field name="group_id" ref="hr_timesheet.group_hr_timesheet_user" />
<field name="tip_description" type="html">
<div>
<b class="tip_title">Tip: Record your Timesheets faster</b>
<p class="tip_content">Record your timesheets in an instant by pressing Shift + the corresponding hotkey to add 15min to your projects.</p>
<img src="https://download.odoocdn.com/digests/hr_timesheet/static/img/digest_tip_timesheets_hotkeys.gif" width="540" class="illustration_border" />
</div>
</field>
</record>
</data>
</odoo>

View File

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Set the JS widget -->
<record id="uom.product_uom_day" model="uom.uom">
<field name="timesheet_widget">float_toggle</field>
</record>
<function model="account.analytic.line" name="_ensure_uom_hours"/>
<record id="uom.product_uom_hour" model="uom.uom">
<field name="timesheet_widget">float_time</field>
</record>
<!-- Force Analytic account creation for projects allowing timesheet (default is True) -->
<function
model="project.project"
name="_init_data_analytic_account"
eval="[]"/>
<record id="internal_project_default_stage" model="project.task.type">
<field name="sequence">1</field>
<field name="name">Internal</field>
</record>
</odoo>

3464
data/hr_timesheet_demo.xml Normal file

File diff suppressed because it is too large Load Diff

1381
i18n/af.po Normal file

File diff suppressed because it is too large Load Diff

1377
i18n/am.po Normal file

File diff suppressed because it is too large Load Diff

1559
i18n/ar.po Normal file

File diff suppressed because it is too large Load Diff

1384
i18n/az.po Normal file

File diff suppressed because it is too large Load Diff

1513
i18n/bg.po Normal file

File diff suppressed because it is too large Load Diff

1382
i18n/bs.po Normal file

File diff suppressed because it is too large Load Diff

1556
i18n/ca.po Normal file

File diff suppressed because it is too large Load Diff

1536
i18n/cs.po Normal file

File diff suppressed because it is too large Load Diff

1530
i18n/da.po Normal file

File diff suppressed because it is too large Load Diff

1581
i18n/de.po Normal file

File diff suppressed because it is too large Load Diff

1384
i18n/el.po Normal file

File diff suppressed because it is too large Load Diff

1379
i18n/en_AU.po Normal file

File diff suppressed because it is too large Load Diff

1380
i18n/en_GB.po Normal file

File diff suppressed because it is too large Load Diff

1574
i18n/es.po Normal file

File diff suppressed because it is too large Load Diff

1575
i18n/es_419.po Normal file

File diff suppressed because it is too large Load Diff

1380
i18n/es_BO.po Normal file

File diff suppressed because it is too large Load Diff

1380
i18n/es_CL.po Normal file

File diff suppressed because it is too large Load Diff

1380
i18n/es_CO.po Normal file

File diff suppressed because it is too large Load Diff

1380
i18n/es_CR.po Normal file

File diff suppressed because it is too large Load Diff

1380
i18n/es_DO.po Normal file

File diff suppressed because it is too large Load Diff

1380
i18n/es_EC.po Normal file

File diff suppressed because it is too large Load Diff

1380
i18n/es_PE.po Normal file

File diff suppressed because it is too large Load Diff

1380
i18n/es_PY.po Normal file

File diff suppressed because it is too large Load Diff

1380
i18n/es_VE.po Normal file

File diff suppressed because it is too large Load Diff

1560
i18n/et.po Normal file

File diff suppressed because it is too large Load Diff

1380
i18n/eu.po Normal file

File diff suppressed because it is too large Load Diff

1527
i18n/fa.po Normal file

File diff suppressed because it is too large Load Diff

1578
i18n/fi.po Normal file

File diff suppressed because it is too large Load Diff

1380
i18n/fo.po Normal file

File diff suppressed because it is too large Load Diff

1582
i18n/fr.po Normal file

File diff suppressed because it is too large Load Diff

1379
i18n/fr_BE.po Normal file

File diff suppressed because it is too large Load Diff

1380
i18n/fr_CA.po Normal file

File diff suppressed because it is too large Load Diff

1380
i18n/gl.po Normal file

File diff suppressed because it is too large Load Diff

1381
i18n/gu.po Normal file

File diff suppressed because it is too large Load Diff

1523
i18n/he.po Normal file

File diff suppressed because it is too large Load Diff

1379
i18n/hi.po Normal file

File diff suppressed because it is too large Load Diff

1392
i18n/hr.po Normal file

File diff suppressed because it is too large Load Diff

1498
i18n/hr_timesheet.pot Normal file

File diff suppressed because it is too large Load Diff

1505
i18n/hu.po Normal file

File diff suppressed because it is too large Load Diff

1379
i18n/hy.po Normal file

File diff suppressed because it is too large Load Diff

1575
i18n/id.po Normal file

File diff suppressed because it is too large Load Diff

1383
i18n/is.po Normal file

File diff suppressed because it is too large Load Diff

1576
i18n/it.po Normal file

File diff suppressed because it is too large Load Diff

1540
i18n/ja.po Normal file

File diff suppressed because it is too large Load Diff

1380
i18n/ka.po Normal file

File diff suppressed because it is too large Load Diff

1380
i18n/kab.po Normal file

File diff suppressed because it is too large Load Diff

1379
i18n/kk.po Normal file

File diff suppressed because it is too large Load Diff

1382
i18n/km.po Normal file

File diff suppressed because it is too large Load Diff

1544
i18n/ko.po Normal file

File diff suppressed because it is too large Load Diff

1381
i18n/lb.po Normal file

File diff suppressed because it is too large Load Diff

1380
i18n/lo.po Normal file

File diff suppressed because it is too large Load Diff

1512
i18n/lt.po Normal file

File diff suppressed because it is too large Load Diff

1510
i18n/lv.po Normal file

File diff suppressed because it is too large Load Diff

1380
i18n/mk.po Normal file

File diff suppressed because it is too large Load Diff

1391
i18n/mn.po Normal file

File diff suppressed because it is too large Load Diff

1385
i18n/nb.po Normal file

File diff suppressed because it is too large Load Diff

1377
i18n/ne.po Normal file

File diff suppressed because it is too large Load Diff

1569
i18n/nl.po Normal file

File diff suppressed because it is too large Load Diff

1554
i18n/pl.po Normal file

File diff suppressed because it is too large Load Diff

1506
i18n/pt.po Normal file

File diff suppressed because it is too large Load Diff

1575
i18n/pt_BR.po Normal file

File diff suppressed because it is too large Load Diff

1396
i18n/ro.po Normal file

File diff suppressed because it is too large Load Diff

1595
i18n/ru.po Normal file

File diff suppressed because it is too large Load Diff

1527
i18n/sk.po Normal file

File diff suppressed because it is too large Load Diff

1512
i18n/sl.po Normal file

File diff suppressed because it is too large Load Diff

1380
i18n/sq.po Normal file

File diff suppressed because it is too large Load Diff

1534
i18n/sr.po Normal file

File diff suppressed because it is too large Load Diff

1383
i18n/sr@latin.po Normal file

File diff suppressed because it is too large Load Diff

1523
i18n/sv.po Normal file

File diff suppressed because it is too large Load Diff

1380
i18n/ta.po Normal file

File diff suppressed because it is too large Load Diff

1379
i18n/te.po Normal file

File diff suppressed because it is too large Load Diff

1562
i18n/th.po Normal file

File diff suppressed because it is too large Load Diff

1550
i18n/tr.po Normal file

File diff suppressed because it is too large Load Diff

1567
i18n/uk.po Normal file

File diff suppressed because it is too large Load Diff

1563
i18n/vi.po Normal file

File diff suppressed because it is too large Load Diff

1537
i18n/zh_CN.po Normal file

File diff suppressed because it is too large Load Diff

1537
i18n/zh_TW.po Normal file

File diff suppressed because it is too large Load Diff

14
models/__init__.py Normal file
View File

@ -0,0 +1,14 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import hr_employee
from . import hr_timesheet
from . import ir_http
from . import ir_ui_menu
from . import res_company
from . import res_config_settings
from . import project_project
from . import project_task
from . import project_update
from . import project_collaborator
from . import uom_uom

70
models/hr_employee.py Normal file
View File

@ -0,0 +1,70 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from ast import literal_eval
from odoo import api, models, fields, _
from odoo.exceptions import UserError
class HrEmployee(models.Model):
_inherit = 'hr.employee'
has_timesheet = fields.Boolean(compute='_compute_has_timesheet')
def _compute_has_timesheet(self):
self.env.cr.execute("""
SELECT id, EXISTS(SELECT 1 FROM account_analytic_line WHERE project_id IS NOT NULL AND employee_id = e.id limit 1)
FROM hr_employee e
WHERE id in %s
""", (tuple(self.ids), ))
result = {eid[0]: eid[1] for eid in self.env.cr.fetchall()}
for employee in self:
employee.has_timesheet = result.get(employee.id, False)
@api.depends('company_id', 'user_id')
@api.depends_context('allowed_company_ids')
def _compute_display_name(self):
super()._compute_display_name()
allowed_company_ids = self.env.context.get('allowed_company_ids', [])
if len(allowed_company_ids) <= 1:
return
employees_count_per_user = {
user.id: count
for user, count in self.env['hr.employee'].sudo()._read_group(
[('user_id', 'in', self.user_id.ids), ('company_id', 'in', allowed_company_ids)],
['user_id'],
['__count'],
)
}
for employee in self:
if employees_count_per_user.get(employee.user_id.id, 0) > 1:
employee.display_name = f'{employee.display_name} - {employee.company_id.name}'
def action_unlink_wizard(self):
wizard = self.env['hr.employee.delete.wizard'].create({
'employee_ids': self.ids,
})
if not self.user_has_groups('hr_timesheet.group_hr_timesheet_approver') and wizard.has_timesheet and not wizard.has_active_employee:
raise UserError(_('You cannot delete employees who have timesheets.'))
return {
'name': _('Confirmation'),
'view_mode': 'form',
'res_model': 'hr.employee.delete.wizard',
'views': [(self.env.ref('hr_timesheet.hr_employee_delete_wizard_form').id, 'form')],
'type': 'ir.actions.act_window',
'res_id': wizard.id,
'target': 'new',
'context': self.env.context,
}
def action_timesheet_from_employee(self):
action = self.env["ir.actions.act_window"]._for_xml_id("hr_timesheet.timesheet_action_from_employee")
context = literal_eval(action['context'].replace('active_id', str(self.id)))
context['create'] = context.get('create', True) and self.active
context['grid_range'] = "week"
action['context'] = context
return action

449
models/hr_timesheet.py Normal file
View File

@ -0,0 +1,449 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from collections import defaultdict
import re
from odoo import api, fields, models, _, _lt
from odoo.exceptions import UserError, AccessError, ValidationError
from odoo.osv import expression
class AccountAnalyticLine(models.Model):
_inherit = 'account.analytic.line'
@api.model
def _get_favorite_project_id(self, employee_id=False):
employee_id = employee_id or self.env.user.employee_id.id
last_timesheet_ids = self.search([
('employee_id', '=', employee_id),
('project_id', '!=', False),
], limit=5)
if len(last_timesheet_ids.project_id) == 1:
return last_timesheet_ids.project_id.id
return False
@api.model
def default_get(self, field_list):
result = super(AccountAnalyticLine, self).default_get(field_list)
if not self.env.context.get('default_employee_id') and 'employee_id' in field_list and result.get('user_id'):
result['employee_id'] = self.env['hr.employee'].search([('user_id', '=', result['user_id']), ('company_id', '=', result.get('company_id', self.env.company.id))], limit=1).id
if not self._context.get('default_project_id') and self._context.get('is_timesheet'):
employee_id = result.get('employee_id', self.env.context.get('default_employee_id', False))
favorite_project_id = self._get_favorite_project_id(employee_id)
if favorite_project_id:
result['project_id'] = favorite_project_id
return result
def _domain_project_id(self):
domain = [('allow_timesheets', '=', True)]
if not self.user_has_groups('hr_timesheet.group_timesheet_manager'):
return expression.AND([domain,
['|', ('privacy_visibility', '!=', 'followers'), ('message_partner_ids', 'in', [self.env.user.partner_id.id])]
])
return domain
def _domain_employee_id(self):
if not self.user_has_groups('hr_timesheet.group_hr_timesheet_approver'):
return [('user_id', '=', self.env.user.id)]
return []
task_id = fields.Many2one(
'project.task', 'Task', index='btree_not_null',
compute='_compute_task_id', store=True, readonly=False,
domain="[('allow_timesheets', '=', True), ('project_id', '=?', project_id)]")
parent_task_id = fields.Many2one('project.task', related='task_id.parent_id', store=True)
project_id = fields.Many2one(
'project.project', 'Project', domain=_domain_project_id, index=True,
compute='_compute_project_id', store=True, readonly=False)
user_id = fields.Many2one(compute='_compute_user_id', store=True, readonly=False)
employee_id = fields.Many2one('hr.employee', "Employee", domain=_domain_employee_id, context={'active_test': False},
help="Define an 'hourly cost' on the employee to track the cost of their time.")
job_title = fields.Char(related='employee_id.job_title')
department_id = fields.Many2one('hr.department', "Department", compute='_compute_department_id', store=True, compute_sudo=True)
manager_id = fields.Many2one('hr.employee', "Manager", related='employee_id.parent_id', store=True)
encoding_uom_id = fields.Many2one('uom.uom', compute='_compute_encoding_uom_id')
partner_id = fields.Many2one(compute='_compute_partner_id', store=True, readonly=False)
readonly_timesheet = fields.Boolean(string="Readonly Timesheet", compute="_compute_readonly_timesheet", compute_sudo=True)
@api.depends('project_id', 'task_id')
def _compute_display_name(self):
analytic_line_with_project = self.filtered('project_id')
super(AccountAnalyticLine, self - analytic_line_with_project)._compute_display_name()
for analytic_line in analytic_line_with_project:
if analytic_line.task_id:
analytic_line.display_name = f"{analytic_line.project_id.display_name} - {analytic_line.task_id.display_name}"
else:
analytic_line.display_name = analytic_line.project_id.display_name
def _is_readonly(self):
self.ensure_one()
# is overridden in other timesheet related modules
return False
def _compute_readonly_timesheet(self):
readonly_timesheets = self.filtered(lambda timesheet: timesheet._is_readonly())
readonly_timesheets.readonly_timesheet = True
(self - readonly_timesheets).readonly_timesheet = False
def _compute_encoding_uom_id(self):
for analytic_line in self:
analytic_line.encoding_uom_id = analytic_line.company_id.timesheet_encode_uom_id
@api.depends('task_id.partner_id', 'project_id.partner_id')
def _compute_partner_id(self):
for timesheet in self:
if timesheet.project_id:
timesheet.partner_id = timesheet.task_id.partner_id or timesheet.project_id.partner_id
@api.depends('task_id')
def _compute_project_id(self):
for line in self:
if not line.task_id.project_id or line.project_id == line.task_id.project_id:
continue
line.project_id = line.task_id.project_id
@api.depends('project_id')
def _compute_task_id(self):
for line in self:
if line.project_id and line.project_id == line.task_id.project_id:
continue
line.task_id = False
@api.onchange('project_id')
def _onchange_project_id(self):
# TODO KBA in master - check to do it "properly", currently:
# This onchange is used to reset the task_id when the project changes.
# Doing it in the compute will remove the task_id when the project of a task changes.
if self.project_id != self.task_id.project_id:
self.task_id = False
@api.depends('employee_id')
def _compute_user_id(self):
for line in self:
line.user_id = line.employee_id.user_id if line.employee_id else self._default_user()
@api.depends('employee_id')
def _compute_department_id(self):
for line in self:
line.department_id = line.employee_id.department_id
def _check_can_write(self, values):
# If it's a basic user then check if the timesheet is his own.
if not (self.user_has_groups('hr_timesheet.group_hr_timesheet_approver') or self.env.su) and any(self.env.user.id != analytic_line.user_id.id for analytic_line in self):
raise AccessError(_("You cannot access timesheets that are not yours."))
def _check_can_create(self):
# override in other modules to check current user has create access
pass
@api.model_create_multi
def create(self, vals_list):
# Before creating a timesheet, we need to put a valid employee_id in the vals
default_user_id = self._default_user()
user_ids = []
employee_ids = []
# 1/ Collect the user_ids and employee_ids from each timesheet vals
vals_list = self._timesheet_preprocess(vals_list)
for vals in vals_list:
if not vals.get('project_id'):
continue
if not vals.get('name'):
vals['name'] = '/'
employee_id = vals.get('employee_id', self._context.get('default_employee_id', False))
if employee_id and employee_id not in employee_ids:
employee_ids.append(employee_id)
else:
user_id = vals.get('user_id', default_user_id)
if user_id not in user_ids:
user_ids.append(user_id)
# 2/ Search all employees related to user_ids and employee_ids, in the selected companies
employees = self.env['hr.employee'].sudo().search([
'&', '|', ('user_id', 'in', user_ids), ('id', 'in', employee_ids), ('company_id', 'in', self.env.companies.ids)
])
# ┌───── in search results = active/in companies ────────> was found with... ─── employee_id ───> (A) There is nothing to do, we will use this employee_id
# 3/ Each employee └──── user_id ──────> (B)** We'll need to select the right employee for this user
# └─ not in search results = archived/not in companies ──> (C) We raise an error as we can't create a timesheet for an archived employee
# ** We can rely on the user to get the employee_id if
# he has an active employee in the company of the timesheet
# or he has only one active employee for all selected companies
valid_employee_per_id = {}
employee_id_per_company_per_user = defaultdict(dict)
for employee in employees:
if employee.id in employee_ids:
valid_employee_per_id[employee.id] = employee
else:
employee_id_per_company_per_user[employee.user_id.id][employee.company_id.id] = employee.id
# 4/ Put valid employee_id in each vals
error_msg = _lt('Timesheets must be created with an active employee in the selected companies.')
for vals in vals_list:
if not vals.get('project_id'):
continue
employee_in_id = vals.get('employee_id', self._context.get('default_employee_id', False))
if employee_in_id:
company = False
if not vals.get('company_id'):
company = self.env['hr.employee'].browse(employee_in_id).company_id
vals['company_id'] = company.id
if not vals.get('product_uom_id'):
vals['product_uom_id'] = company.project_time_mode_id.id if company else self.env['res.company'].browse(vals.get('company_id', self.env.company.id)).project_time_mode_id.id
if employee_in_id in valid_employee_per_id:
vals['user_id'] = valid_employee_per_id[employee_in_id].sudo().user_id.id # (A) OK
continue
else:
raise ValidationError(error_msg) # (C) KO
else:
user_id = vals.get('user_id', default_user_id) # (B)...
# ...Look for an employee, with ** conditions
employee_per_company = employee_id_per_company_per_user.get(user_id)
employee_out_id = False
if employee_per_company:
company_id = list(employee_per_company)[0] if len(employee_per_company) == 1\
else vals.get('company_id', self.env.company.id)
employee_out_id = employee_per_company.get(company_id, False)
if employee_out_id:
vals['employee_id'] = employee_out_id
vals['user_id'] = user_id
company = False
if not vals.get('company_id'):
company = self.env['hr.employee'].browse(employee_out_id).company_id
vals['company_id'] = company.id
if not vals.get('product_uom_id'):
vals['product_uom_id'] = company.project_time_mode_id.id if company else self.env['res.company'].browse(vals.get('company_id', self.env.company.id)).project_time_mode_id.id
else: # ...and raise an error if they fail
raise ValidationError(error_msg)
# 5/ Finally, create the timesheets
lines = super(AccountAnalyticLine, self).create(vals_list)
lines._check_can_create()
for line, values in zip(lines, vals_list):
if line.project_id: # applied only for timesheet
line._timesheet_postprocess(values)
return lines
def write(self, values):
self._check_can_write(values)
values = self._timesheet_preprocess([values])[0]
if values.get('employee_id'):
employee = self.env['hr.employee'].browse(values['employee_id'])
if not employee.active:
raise UserError(_('You cannot set an archived employee to the existing timesheets.'))
if 'name' in values and not values.get('name'):
values['name'] = '/'
if 'company_id' in values and not values.get('company_id'):
del values['company_id']
result = super(AccountAnalyticLine, self).write(values)
# applied only for timesheet
self.filtered(lambda t: t.project_id)._timesheet_postprocess(values)
return result
@api.model
def _get_view_cache_key(self, view_id=None, view_type='form', **options):
"""The override of _get_view changing the time field labels according to the company timesheet encoding UOM
makes the view cache dependent on the company timesheet encoding uom"""
key = super()._get_view_cache_key(view_id, view_type, **options)
return key + (self.env.company.timesheet_encode_uom_id,)
@api.model
def get_views(self, views, options=None):
res = super().get_views(views, options)
if options and options.get('toolbar'):
wip_report_id = None
def get_wip_report_id():
return self.env['ir.model.data']._xmlid_to_res_id("mrp_account.wip_report", raise_if_not_found=False)
for view_data in res['views'].values():
print_data_list = view_data.get('toolbar', {}).get('print')
if print_data_list:
if wip_report_id is None and re.search(r'widget="timesheet_uom(\w)*"', view_data['arch']):
wip_report_id = get_wip_report_id()
if wip_report_id:
view_data['toolbar']['print'] = [print_data for print_data in print_data_list if print_data['id'] != wip_report_id]
return res
@api.model
def _get_view(self, view_id=None, view_type='form', **options):
""" Set the correct label for `unit_amount`, depending on company UoM """
arch, view = super()._get_view(view_id, view_type, **options)
# Use of sudo as the portal user doesn't have access to uom
arch = self.sudo()._apply_timesheet_label(arch, view_type=view_type)
arch = self._apply_time_label(arch, related_model=self._name)
return arch, view
@api.model
def _apply_timesheet_label(self, view_node, view_type='form'):
doc = view_node
encoding_uom = self.env.company.timesheet_encode_uom_id
# Here, we select only the unit_amount field having no string set to give priority to
# custom inheretied view stored in database. Even if normally, no xpath can be done on
# 'string' attribute.
for node in doc.xpath("//field[@name='unit_amount'][@widget='timesheet_uom'][not(@string)]"):
node.set('string', _('%s Spent', re.sub(r'[\(\)]', '', encoding_uom.name or '')))
return doc
@api.model
def _apply_time_label(self, view_node, related_model):
doc = view_node
Model = self.env[related_model]
# Just fetch the name of the uom in `timesheet_encode_uom_id` of the current company
encoding_uom_name = self.env.company.timesheet_encode_uom_id.with_context(prefetch_fields=False).sudo().name
for node in doc.xpath("//field[@widget='timesheet_uom'][not(@string)] | //field[@widget='timesheet_uom_no_toggle'][not(@string)]"):
name_with_uom = re.sub(re.escape(_('Hours')) + "|Hours", encoding_uom_name or '', Model._fields[node.get('name')]._description_string(self.env), flags=re.IGNORECASE)
node.set('string', name_with_uom)
return doc
def _timesheet_get_portal_domain(self):
if self.env.user.has_group('hr_timesheet.group_hr_timesheet_user'):
# Then, he is internal user, and we take the domain for this current user
return self.env['ir.rule']._compute_domain(self._name)
return [
'|',
'&',
'|',
('task_id.project_id.message_partner_ids', 'child_of', [self.env.user.partner_id.commercial_partner_id.id]),
('task_id.message_partner_ids', 'child_of', [self.env.user.partner_id.commercial_partner_id.id]),
('task_id.project_id.privacy_visibility', '=', 'portal'),
'&',
('task_id', '=', False),
'&',
('project_id.message_partner_ids', 'child_of', [self.env.user.partner_id.commercial_partner_id.id]),
('project_id.privacy_visibility', '=', 'portal')
]
def _timesheet_preprocess(self, vals_list):
""" Deduce other field values from the one given.
Overrride this to compute on the fly some field that can not be computed fields.
:param vals_list: list of dict from `create`or `write`.
"""
timesheet_indices = set()
task_ids, project_ids, account_ids = set(), set(), set()
for index, vals in enumerate(vals_list):
if not vals.get('project_id') and not vals.get('task_id'):
continue
timesheet_indices.add(index)
if vals.get('task_id'):
task_ids.add(vals['task_id'])
elif vals.get('project_id'):
project_ids.add(vals['project_id'])
if vals.get('account_id'):
account_ids.add(vals['account_id'])
task_per_id = {}
if task_ids:
tasks = self.env['project.task'].sudo().browse(task_ids)
for task in tasks:
task_per_id[task.id] = task
if not task.project_id:
raise ValidationError(_('Timesheets cannot be created on a private task.'))
account_ids = account_ids.union(tasks.analytic_account_id.ids, tasks.project_id.analytic_account_id.ids)
project_per_id = {}
if project_ids:
projects = self.env['project.project'].sudo().browse(project_ids)
account_ids = account_ids.union(projects.analytic_account_id.ids)
project_per_id = {p.id: p for p in projects}
accounts = self.env['account.analytic.account'].sudo().browse(account_ids)
account_per_id = {account.id: account for account in accounts}
uom_id_per_company = {
company: company.project_time_mode_id.id
for company in accounts.company_id
}
for index in timesheet_indices:
vals = vals_list[index]
data = task_per_id[vals['task_id']] if vals.get('task_id') else project_per_id[vals['project_id']]
if not vals.get('project_id'):
vals['project_id'] = data.project_id.id
if not vals.get('account_id'):
account = data._get_task_analytic_account_id() if vals.get('task_id') else data.analytic_account_id
if not account or not account.active:
raise ValidationError(_('Timesheets must be created on a project or a task with an active analytic account.'))
vals['account_id'] = account.id
vals['company_id'] = account.company_id.id or data.company_id.id
if not vals.get('product_uom_id'):
company = account_per_id[vals['account_id']].company_id or data.company_id
vals['product_uom_id'] = uom_id_per_company.get(company.id, company.project_time_mode_id.id) or self.env.company.project_time_mode_id.id
return vals_list
def _timesheet_postprocess(self, values):
""" Hook to update record one by one according to the values of a `write` or a `create`. """
sudo_self = self.sudo() # this creates only one env for all operation that required sudo() in `_timesheet_postprocess_values`override
values_to_write = self._timesheet_postprocess_values(values)
for timesheet in sudo_self:
if values_to_write[timesheet.id]:
timesheet.write(values_to_write[timesheet.id])
return values
def _timesheet_postprocess_values(self, values):
""" Get the addionnal values to write on record
:param dict values: values for the model's fields, as a dictionary::
{'field_name': field_value, ...}
:return: a dictionary mapping each record id to its corresponding
dictionary values to write (may be empty).
"""
result = {id_: {} for id_ in self.ids}
sudo_self = self.sudo() # this creates only one env for all operation that required sudo()
# (re)compute the amount (depending on unit_amount, employee_id for the cost, and account_id for currency)
if any(field_name in values for field_name in ['unit_amount', 'employee_id', 'account_id']):
for timesheet in sudo_self:
cost = timesheet._hourly_cost()
amount = -timesheet.unit_amount * cost
amount_converted = timesheet.employee_id.currency_id._convert(
amount, timesheet.account_id.currency_id or timesheet.currency_id, self.env.company, timesheet.date)
result[timesheet.id].update({
'amount': amount_converted,
})
return result
def _is_timesheet_encode_uom_day(self):
company_uom = self.env.company.timesheet_encode_uom_id
return company_uom == self.env.ref('uom.product_uom_day')
@api.model
def _convert_hours_to_days(self, time):
uom_hour = self.env.ref('uom.product_uom_hour')
uom_day = self.env.ref('uom.product_uom_day')
return round(uom_hour._compute_quantity(time, uom_day, raise_if_failure=False), 2)
def _get_timesheet_time_day(self):
return self._convert_hours_to_days(self.unit_amount)
def _hourly_cost(self):
self.ensure_one()
return self.employee_id.hourly_cost or 0.0
def _get_report_base_filename(self):
task_ids = self.task_id
if len(task_ids) == 1:
return _('Timesheets - %s', task_ids.name)
return _('Timesheets')
def _default_user(self):
return self.env.context.get('user_id', self.env.user.id)
@api.model
def _ensure_uom_hours(self):
uom_hours = self.env.ref('uom.product_uom_hour', raise_if_not_found=False)
if not uom_hours:
uom_hours = self.env['uom.uom'].create({
'name': "Hours",
'category_id': self.env.ref('uom.uom_categ_wtime').id,
'factor': 8,
'uom_type': "smaller",
})
self.env['ir.model.data'].create({
'name': 'product_uom_hour',
'model': 'uom.uom',
'module': 'uom',
'res_id': uom_hours.id,
'noupdate': True,
})

43
models/ir_http.py Normal file
View File

@ -0,0 +1,43 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models
class Http(models.AbstractModel):
_inherit = 'ir.http'
def session_info(self):
""" The widget 'timesheet_uom' needs to know which UoM conversion factor and which javascript
widget to apply, depending on the current company.
"""
result = super(Http, self).session_info()
if self.env.user._is_internal():
company_ids = self.env.user.company_ids
for company in company_ids:
result["user_companies"]["allowed_companies"][company.id].update({
"timesheet_uom_id": company.timesheet_encode_uom_id.id,
"timesheet_uom_factor": company.project_time_mode_id._compute_quantity(
1.0,
company.timesheet_encode_uom_id,
round=False
),
})
result["uom_ids"] = self.get_timesheet_uoms()
return result
@api.model
def get_timesheet_uoms(self):
company_ids = self.env.user.company_ids
uom_ids = company_ids.mapped('timesheet_encode_uom_id') | \
company_ids.mapped('project_time_mode_id')
return {
uom.id:
{
'id': uom.id,
'name': uom.name,
'rounding': uom.rounding,
'timesheet_widget': uom.timesheet_widget,
} for uom in uom_ids
}

14
models/ir_ui_menu.py Normal file
View File

@ -0,0 +1,14 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class IrUiMenu(models.Model):
_inherit = 'ir.ui.menu'
def _load_menus_blacklist(self):
res = super()._load_menus_blacklist()
if self.env.user.has_group('hr_timesheet.group_hr_timesheet_approver'):
res.append(self.env.ref('hr_timesheet.timesheet_menu_activity_user').id)
return res

View File

@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models
class ProjectCollaborator(models.Model):
_inherit = 'project.collaborator'
@api.model
def _toggle_project_sharing_portal_rules(self, active):
super()._toggle_project_sharing_portal_rules(active)
# ir.model.access
access_timesheet_portal = self.env.ref('hr_timesheet.access_account_analytic_line_portal_user').sudo()
if access_timesheet_portal.active != active:
access_timesheet_portal.write({'active': active})
# ir.rule
timesheet_portal_ir_rule = self.env.ref('hr_timesheet.timesheet_line_rule_portal_user').sudo()
if timesheet_portal_ir_rule.active != active:
timesheet_portal_ir_rule.write({'active': active})

285
models/project_project.py Normal file
View File

@ -0,0 +1,285 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from collections import defaultdict
from odoo import models, fields, api, _, _lt
from odoo.exceptions import ValidationError, RedirectWarning
class Project(models.Model):
_inherit = "project.project"
allow_timesheets = fields.Boolean(
"Timesheets", compute='_compute_allow_timesheets', store=True, readonly=False,
default=True)
analytic_account_id = fields.Many2one(
# note: replaces ['|', ('company_id', '=', False), ('company_id', '=', company_id)]
domain="""[
'|', ('company_id', '=', False), ('company_id', '=?', company_id),
('partner_id', '=?', partner_id),
]"""
)
timesheet_ids = fields.One2many('account.analytic.line', 'project_id', 'Associated Timesheets')
timesheet_encode_uom_id = fields.Many2one('uom.uom', compute='_compute_timesheet_encode_uom_id')
total_timesheet_time = fields.Integer(
compute='_compute_total_timesheet_time', groups='hr_timesheet.group_hr_timesheet_user',
help="Total number of time (in the proper UoM) recorded in the project, rounded to the unit.", compute_sudo=True)
encode_uom_in_days = fields.Boolean(compute='_compute_encode_uom_in_days')
is_internal_project = fields.Boolean(compute='_compute_is_internal_project', search='_search_is_internal_project')
remaining_hours = fields.Float(compute='_compute_remaining_hours', string='Remaining Invoiced Time', compute_sudo=True)
is_project_overtime = fields.Boolean('Project in Overtime', compute='_compute_remaining_hours', search='_search_is_project_overtime', compute_sudo=True)
allocated_hours = fields.Float(string='Allocated Hours')
def _compute_encode_uom_in_days(self):
self.encode_uom_in_days = self.env.company.timesheet_encode_uom_id == self.env.ref('uom.product_uom_day')
@api.depends('company_id', 'company_id.timesheet_encode_uom_id')
@api.depends_context('company')
def _compute_timesheet_encode_uom_id(self):
for project in self:
project.timesheet_encode_uom_id = project.company_id.timesheet_encode_uom_id or self.env.company.timesheet_encode_uom_id
@api.depends('analytic_account_id')
def _compute_allow_timesheets(self):
without_account = self.filtered(lambda t: not t.analytic_account_id and t._origin)
without_account.update({'allow_timesheets': False})
@api.depends('company_id')
def _compute_is_internal_project(self):
for project in self:
project.is_internal_project = project == project.company_id.internal_project_id
@api.model
def _search_is_internal_project(self, operator, value):
if not isinstance(value, bool):
raise ValueError(_('Invalid value: %s', value))
if operator not in ['=', '!=']:
raise ValueError(_('Invalid operator: %s', operator))
query = """
SELECT C.internal_project_id
FROM res_company C
WHERE C.internal_project_id IS NOT NULL
"""
if (operator == '=' and value is True) or (operator == '!=' and value is False):
operator_new = 'inselect'
else:
operator_new = 'not inselect'
return [('id', operator_new, (query, ()))]
@api.model
def _get_view_cache_key(self, view_id=None, view_type='form', **options):
"""The override of _get_view changing the time field labels according to the company timesheet encoding UOM
makes the view cache dependent on the company timesheet encoding uom"""
key = super()._get_view_cache_key(view_id, view_type, **options)
return key + (self.env.company.timesheet_encode_uom_id,)
@api.model
def _get_view(self, view_id=None, view_type='form', **options):
arch, view = super()._get_view(view_id, view_type, **options)
if view_type in ['tree', 'form'] and self.env.company.timesheet_encode_uom_id == self.env.ref('uom.product_uom_day'):
arch = self.env['account.analytic.line']._apply_time_label(arch, related_model=self._name)
return arch, view
@api.depends('allow_timesheets', 'timesheet_ids')
def _compute_remaining_hours(self):
timesheets_read_group = self.env['account.analytic.line']._read_group(
[('project_id', 'in', self.ids)],
['project_id'],
['unit_amount:sum'],
)
timesheet_time_dict = {project.id: unit_amount_sum for project, unit_amount_sum in timesheets_read_group}
for project in self:
project.remaining_hours = project.allocated_hours - timesheet_time_dict.get(project.id, 0)
project.is_project_overtime = project.remaining_hours < 0
@api.model
def _search_is_project_overtime(self, operator, value):
if not isinstance(value, bool):
raise ValueError(_('Invalid value: %s', value))
if operator not in ['=', '!=']:
raise ValueError(_('Invalid operator: %s', operator))
query = """
SELECT Project.id
FROM project_project AS Project
JOIN project_task AS Task
ON Project.id = Task.project_id
WHERE Project.allocated_hours > 0
AND Project.allow_timesheets = TRUE
AND Task.parent_id IS NULL
AND Task.state IN ('01_in_progress', '02_changes_requested', '03_approved', '04_waiting_normal')
GROUP BY Project.id
HAVING Project.allocated_hours - SUM(Task.effective_hours) < 0
"""
if (operator == '=' and value is True) or (operator == '!=' and value is False):
operator_new = 'inselect'
else:
operator_new = 'not inselect'
return [('id', operator_new, (query, ()))]
@api.constrains('allow_timesheets', 'analytic_account_id')
def _check_allow_timesheet(self):
for project in self:
if project.allow_timesheets and not project.analytic_account_id:
raise ValidationError(_('You cannot use timesheets without an analytic account.'))
@api.depends('timesheet_ids', 'timesheet_encode_uom_id')
def _compute_total_timesheet_time(self):
timesheets_read_group = self.env['account.analytic.line']._read_group(
[('project_id', 'in', self.ids)],
['project_id', 'product_uom_id'],
['unit_amount:sum'],
)
timesheet_time_dict = defaultdict(list)
for project, product_uom, unit_amount_sum in timesheets_read_group:
timesheet_time_dict[project.id].append((product_uom, unit_amount_sum))
for project in self:
# Timesheets may be stored in a different unit of measure, so first
# we convert all of them to the reference unit
# if the timesheet has no product_uom_id then we take the one of the project
total_time = 0.0
for product_uom, unit_amount in timesheet_time_dict[project.id]:
factor = (product_uom or project.timesheet_encode_uom_id).factor_inv
total_time += unit_amount * (1.0 if project.encode_uom_in_days else factor)
# Now convert to the proper unit of measure set in the settings
total_time *= project.timesheet_encode_uom_id.factor
project.total_timesheet_time = int(round(total_time))
@api.model_create_multi
def create(self, vals_list):
""" Create an analytic account if project allow timesheet and don't provide one
Note: create it before calling super() to avoid raising the ValidationError from _check_allow_timesheet
"""
defaults = self.default_get(['allow_timesheets', 'analytic_account_id'])
for vals in vals_list:
allow_timesheets = vals.get('allow_timesheets', defaults.get('allow_timesheets'))
analytic_account_id = vals.get('analytic_account_id', defaults.get('analytic_account_id'))
if allow_timesheets and not analytic_account_id:
analytic_account = self._create_analytic_account_from_values(vals)
vals['analytic_account_id'] = analytic_account.id
return super().create(vals_list)
def write(self, values):
# create the AA for project still allowing timesheet
if values.get('allow_timesheets') and not values.get('analytic_account_id'):
for project in self:
if not project.analytic_account_id:
project._create_analytic_account()
return super(Project, self).write(values)
@api.depends('is_internal_project', 'company_id')
@api.depends_context('allowed_company_ids')
def _compute_display_name(self):
super()._compute_display_name()
if len(self.env.context.get('allowed_company_ids', [])) <= 1:
return
for project in self:
if project.is_internal_project:
project.display_name = f'{project.display_name} - {project.company_id.name}'
@api.model
def _init_data_analytic_account(self):
self.search([('analytic_account_id', '=', False), ('allow_timesheets', '=', True)])._create_analytic_account()
@api.ondelete(at_uninstall=False)
def _unlink_except_contains_entries(self):
"""
If some projects to unlink have some timesheets entries, these
timesheets entries must be unlinked first.
In this case, a warning message is displayed through a RedirectWarning
and allows the user to see timesheets entries to unlink.
"""
projects_with_timesheets = self.filtered(lambda p: p.timesheet_ids)
if projects_with_timesheets:
if len(projects_with_timesheets) > 1:
warning_msg = _("These projects have some timesheet entries referencing them. Before removing these projects, you have to remove these timesheet entries.")
else:
warning_msg = _("This project has some timesheet entries referencing it. Before removing this project, you have to remove these timesheet entries.")
raise RedirectWarning(
warning_msg, self.env.ref('hr_timesheet.timesheet_action_project').id,
_('See timesheet entries'), {'active_ids': projects_with_timesheets.ids})
def _convert_project_uom_to_timesheet_encode_uom(self, time):
uom_from = self.company_id.project_time_mode_id
uom_to = self.env.company.timesheet_encode_uom_id
return round(uom_from._compute_quantity(time, uom_to, raise_if_failure=False), 2)
def action_project_timesheets(self):
action = self.env['ir.actions.act_window']._for_xml_id('hr_timesheet.act_hr_timesheet_line_by_project')
action['display_name'] = _("%(name)s's Timesheets", name=self.name)
return action
# ----------------------------
# Project Updates
# ----------------------------
def _get_stat_buttons(self):
buttons = super(Project, self)._get_stat_buttons()
if not self.allow_timesheets or not self.env.user.has_group("hr_timesheet.group_hr_timesheet_user"):
return buttons
encode_uom = self.env.company.timesheet_encode_uom_id
uom_ratio = self.env.ref('uom.product_uom_hour').factor / encode_uom.factor
allocated = self.allocated_hours / uom_ratio
effective = self.total_timesheet_time / uom_ratio
color = ""
if allocated:
number = f"{round(effective)} / {round(allocated)} {encode_uom.name}"
success_rate = round(100 * effective / allocated)
if success_rate > 100:
number = _lt(
"%(effective)s / %(allocated)s %(uom_name)s",
effective=round(effective),
allocated=round(allocated),
uom_name=encode_uom.name,
)
color = "text-danger"
else:
number = _lt(
"%(effective)s / %(allocated)s %(uom_name)s (%(success_rate)s%%)",
effective=round(effective),
allocated=round(allocated),
uom_name=encode_uom.name,
success_rate=success_rate,
)
if success_rate >= 80:
color = "text-warning"
else:
color = "text-success"
else:
number = _lt(
"%(effective)s %(uom_name)s",
effective=round(effective),
uom_name=encode_uom.name,
)
buttons.append({
"icon": f"clock-o {color}",
"text": _lt("Timesheets"),
"number": number,
"action_type": "object",
"action": "action_project_timesheets",
"show": True,
"sequence": 2,
})
if allocated and success_rate > 100:
buttons.append({
"icon": f"warning {color}",
"text": _lt("Extra Time"),
"number": _lt(
"%(exceeding_hours)s %(uom_name)s (+%(exceeding_rate)s%%)",
exceeding_hours=round(effective - allocated),
uom_name=encode_uom.name,
exceeding_rate=round(100 * (effective - allocated) / allocated),
),
"action_type": "object",
"action": "action_project_timesheets",
"show": True,
"sequence": 3,
})
return buttons

248
models/project_task.py Normal file
View File

@ -0,0 +1,248 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import re
from odoo import models, fields, api, _
from odoo.exceptions import UserError, RedirectWarning
from odoo.addons.rating.models.rating_data import OPERATOR_MAPPING
PROJECT_TASK_READABLE_FIELDS = {
'allow_timesheets',
'analytic_account_active',
'effective_hours',
'encode_uom_in_days',
'allocated_hours',
'progress',
'overtime',
'remaining_hours',
'subtask_effective_hours',
'subtask_allocated_hours',
'timesheet_ids',
'total_hours_spent',
}
class Task(models.Model):
_name = "project.task"
_inherit = "project.task"
project_id = fields.Many2one(domain="['|', ('company_id', '=', False), ('company_id', '=?', company_id), ('is_internal_project', '=', False)]")
analytic_account_active = fields.Boolean("Active Analytic Account", compute='_compute_analytic_account_active', compute_sudo=True, recursive=True)
allow_timesheets = fields.Boolean(
"Allow timesheets",
compute='_compute_allow_timesheets', search='_search_allow_timesheets',
compute_sudo=True, readonly=True,
help="Timesheets can be logged on this task.")
remaining_hours = fields.Float("Remaining Hours", compute='_compute_remaining_hours', store=True, readonly=True, help="Number of allocated hours minus the number of hours spent.")
remaining_hours_percentage = fields.Float(compute='_compute_remaining_hours_percentage', search='_search_remaining_hours_percentage')
effective_hours = fields.Float("Hours Spent", compute='_compute_effective_hours', compute_sudo=True, store=True)
total_hours_spent = fields.Float("Total Hours", compute='_compute_total_hours_spent', store=True, help="Time spent on this task and its sub-tasks (and their own sub-tasks).")
progress = fields.Float("Progress", compute='_compute_progress_hours', store=True, group_operator="avg")
overtime = fields.Float(compute='_compute_progress_hours', store=True)
subtask_effective_hours = fields.Float("Hours Spent on Sub-Tasks", compute='_compute_subtask_effective_hours', recursive=True, store=True, help="Time spent on the sub-tasks (and their own sub-tasks) of this task.")
timesheet_ids = fields.One2many('account.analytic.line', 'task_id', 'Timesheets')
encode_uom_in_days = fields.Boolean(compute='_compute_encode_uom_in_days', default=lambda self: self._uom_in_days())
display_name = fields.Char(help="""Use these keywords in the title to set new tasks:\n
30h Allocate 30 hours to the task
#tags Set tags on the task
@user Assign the task to a user
! Set the task a high priority\n
Make sure to use the right format and order e.g. Improve the configuration screen 5h #feature #v16 @Mitchell !""",
)
@property
def SELF_READABLE_FIELDS(self):
return super().SELF_READABLE_FIELDS | PROJECT_TASK_READABLE_FIELDS
@api.constrains('project_id')
def _check_project_root(self):
private_tasks = self.filtered(lambda t: not t.project_id)
if private_tasks and self.env['account.analytic.line'].sudo().search_count([('task_id', 'in', private_tasks.ids)], limit=1):
raise UserError(_("This task cannot be private because there are some timesheets linked to it."))
def _uom_in_days(self):
return self.env.company.timesheet_encode_uom_id == self.env.ref('uom.product_uom_day')
def _compute_encode_uom_in_days(self):
self.encode_uom_in_days = self._uom_in_days()
@api.depends('project_id.allow_timesheets')
def _compute_allow_timesheets(self):
for task in self:
task.allow_timesheets = task.project_id.allow_timesheets
def _search_allow_timesheets(self, operator, value):
query = self.env['project.project'].sudo()._search([
('allow_timesheets', operator, value),
])
return [('project_id', 'in', query)]
@api.depends('analytic_account_id.active', 'project_id.analytic_account_id.active')
def _compute_analytic_account_active(self):
""" Overridden in sale_timesheet """
for task in self:
task.analytic_account_active = task._get_task_analytic_account_id().active
@api.depends('timesheet_ids.unit_amount')
def _compute_effective_hours(self):
if not any(self._ids):
for task in self:
task.effective_hours = sum(task.timesheet_ids.mapped('unit_amount'))
return
timesheet_read_group = self.env['account.analytic.line']._read_group([('task_id', 'in', self.ids)], ['task_id'], ['unit_amount:sum'])
timesheets_per_task = {task.id: amount for task, amount in timesheet_read_group}
for task in self:
task.effective_hours = timesheets_per_task.get(task.id, 0.0)
@api.depends('effective_hours', 'subtask_effective_hours', 'allocated_hours')
def _compute_progress_hours(self):
for task in self:
if (task.allocated_hours > 0.0):
task_total_hours = task.effective_hours + task.subtask_effective_hours
task.overtime = max(task_total_hours - task.allocated_hours, 0)
task.progress = round(100.0 * task_total_hours / task.allocated_hours, 2)
else:
task.progress = 0.0
task.overtime = 0
@api.depends('allocated_hours', 'remaining_hours')
def _compute_remaining_hours_percentage(self):
for task in self:
if task.allocated_hours > 0.0:
task.remaining_hours_percentage = task.remaining_hours / task.allocated_hours
else:
task.remaining_hours_percentage = 0.0
def _search_remaining_hours_percentage(self, operator, value):
if operator not in OPERATOR_MAPPING:
raise NotImplementedError(_('This operator %s is not supported in this search method.', operator))
query = f"""
SELECT id
FROM {self._table}
WHERE remaining_hours > 0
AND allocated_hours > 0
AND remaining_hours / allocated_hours {operator} %s
"""
return [('id', 'inselect', (query, (value,)))]
@api.depends('effective_hours', 'subtask_effective_hours', 'allocated_hours')
def _compute_remaining_hours(self):
for task in self:
task.remaining_hours = task.allocated_hours - task.effective_hours - task.subtask_effective_hours
@api.depends('effective_hours', 'subtask_effective_hours')
def _compute_total_hours_spent(self):
for task in self:
task.total_hours_spent = task.effective_hours + task.subtask_effective_hours
@api.depends('child_ids.effective_hours', 'child_ids.subtask_effective_hours')
def _compute_subtask_effective_hours(self):
for task in self.with_context(active_test=False):
task.subtask_effective_hours = sum(child_task.effective_hours + child_task.subtask_effective_hours for child_task in task.child_ids)
def _get_group_pattern(self):
return {
**super()._get_group_pattern(),
'allocated_hours': r'\s(\d+(?:\.\d+)?)[hH]',
}
def _prepare_pattern_groups(self):
return [self._get_group_pattern()['allocated_hours']] + super()._prepare_pattern_groups()
def _get_cannot_start_with_patterns(self):
return super()._get_cannot_start_with_patterns() + [r'(?!\d+(?:\.\d+)?(?:h|H))']
def _extract_allocated_hours(self):
allocated_hours_group = self._get_group_pattern()['allocated_hours']
if self.allow_timesheets:
self.allocated_hours = sum(float(num) for num in re.findall(allocated_hours_group, self.display_name))
self.display_name, dummy = re.subn(allocated_hours_group, '', self.display_name)
def _get_groups(self):
return [lambda task: task._extract_allocated_hours()] + super()._get_groups()
def action_view_subtask_timesheet(self):
self.ensure_one()
task_ids = self.with_context(active_test=False)._get_subtask_ids_per_task_id().get(self.id, [])
action = self.env["ir.actions.actions"]._for_xml_id("hr_timesheet.timesheet_action_all")
graph_view_id = self.env.ref("hr_timesheet.view_hr_timesheet_line_graph_by_employee").id
new_views = []
for view in action['views']:
if view[1] == 'graph':
view = (graph_view_id, 'graph')
new_views.insert(0, view) if view[1] == 'tree' else new_views.append(view)
action.update({
'display_name': _('Timesheets'),
'context': {'default_project_id': self.project_id.id, 'grid_range': 'week'},
'domain': [('project_id', '!=', False), ('task_id', 'in', task_ids)],
'views': new_views,
})
return action
def _get_timesheet(self):
# Is override in sale_timesheet
return self.timesheet_ids
@api.depends('allow_timesheets', 'allocated_hours', 'encode_uom_in_days', 'remaining_hours')
@api.depends_context('hr_timesheet_display_remaining_hours')
def _compute_display_name(self):
super()._compute_display_name()
if self.env.context.get('hr_timesheet_display_remaining_hours'):
for task in self:
if task.allow_timesheets and task.allocated_hours > 0 and task.encode_uom_in_days:
days_left = _("(%s days remaining)", task._convert_hours_to_days(task.remaining_hours))
task.display_name = task.display_name + "\u00A0" + days_left
elif task.allow_timesheets and task.allocated_hours > 0:
hours, mins = (str(int(duration)).rjust(2, '0') for duration in divmod(abs(task.remaining_hours) * 60, 60))
hours_left = _(
"(%(sign)s%(hours)s:%(minutes)s remaining)",
sign='-' if task.remaining_hours < 0 else '',
hours=hours,
minutes=mins,
)
task.display_name = task.display_name + "\u00A0" + hours_left
@api.model
def _get_view_cache_key(self, view_id=None, view_type='form', **options):
"""The override of _get_view changing the time field labels according to the company timesheet encoding UOM
makes the view cache dependent on the company timesheet encoding uom"""
key = super()._get_view_cache_key(view_id, view_type, **options)
return key + (self.env.company.timesheet_encode_uom_id,)
@api.model
def _get_view(self, view_id=None, view_type='form', **options):
""" Set the correct label for `unit_amount`, depending on company UoM """
arch, view = super()._get_view(view_id, view_type, **options)
# Use of sudo as the portal user doesn't have access to uom
arch = self.env['account.analytic.line'].sudo()._apply_timesheet_label(arch)
if view_type in ['tree', 'pivot', 'graph', 'form'] and self.env.company.timesheet_encode_uom_id == self.env.ref('uom.product_uom_day'):
arch = self.env['account.analytic.line']._apply_time_label(arch, related_model=self._name)
return arch, view
@api.ondelete(at_uninstall=False)
def _unlink_except_contains_entries(self):
"""
If some tasks to unlink have some timesheets entries, these
timesheets entries must be unlinked first.
In this case, a warning message is displayed through a RedirectWarning
and allows the user to see timesheets entries to unlink.
"""
timesheet_data = self.env['account.analytic.line'].sudo()._read_group(
[('task_id', 'in', self.ids)],
['task_id'],
)
task_with_timesheets_ids = [task.id for task, in timesheet_data]
if task_with_timesheets_ids:
if len(task_with_timesheets_ids) > 1:
warning_msg = _("These tasks have some timesheet entries referencing them. Before removing these tasks, you have to remove these timesheet entries.")
else:
warning_msg = _("This task has some timesheet entries referencing it. Before removing this task, you have to remove these timesheet entries.")
raise RedirectWarning(
warning_msg, self.env.ref('hr_timesheet.timesheet_action_task').id,
_('See timesheet entries'), {'active_ids': task_with_timesheets_ids})
@api.model
def _convert_hours_to_days(self, time):
uom_hour = self.env.ref('uom.product_uom_hour')
uom_day = self.env.ref('uom.product_uom_day')
return round(uom_hour._compute_quantity(time, uom_day, raise_if_failure=False), 2)

39
models/project_update.py Normal file
View File

@ -0,0 +1,39 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models, api
class ProjectUpdate(models.Model):
_inherit = "project.update"
display_timesheet_stats = fields.Boolean(compute="_compute_display_timesheet_stats")
allocated_time = fields.Integer("Allocated Time", readonly=True)
timesheet_time = fields.Integer("Timesheet Time", readonly=True)
timesheet_percentage = fields.Integer(compute="_compute_timesheet_percentage")
uom_id = fields.Many2one("uom.uom", "Unit Of Measure", readonly=True)
def _compute_timesheet_percentage(self):
for update in self:
update.timesheet_percentage = update.allocated_time and round(update.timesheet_time * 100 / update.allocated_time)
def _compute_display_timesheet_stats(self):
for update in self:
update.display_timesheet_stats = update.project_id.allow_timesheets
# ---------------------------------
# ORM Override
# ---------------------------------
@api.model_create_multi
def create(self, vals_list):
updates = super().create(vals_list)
encode_uom = self.env.company.timesheet_encode_uom_id
ratio = self.env.ref("uom.product_uom_hour").ratio / encode_uom.ratio
for update in updates:
project = update.project_id
project.sudo().last_update_id = update
update.write({
"uom_id": encode_uom,
"allocated_time": round(project.allocated_hours / ratio),
"timesheet_time": round(project.total_timesheet_time / ratio),
})
return updates

81
models/res_company.py Normal file
View File

@ -0,0 +1,81 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
class ResCompany(models.Model):
_inherit = 'res.company'
@api.model
def _default_project_time_mode_id(self):
uom = self.env.ref('uom.product_uom_hour', raise_if_not_found=False)
wtime = self.env.ref('uom.uom_categ_wtime')
if not uom:
uom = self.env['uom.uom'].search([('category_id', '=', wtime.id), ('uom_type', '=', 'reference')], limit=1)
if not uom:
uom = self.env['uom.uom'].search([('category_id', '=', wtime.id)], limit=1)
return uom
@api.model
def _default_timesheet_encode_uom_id(self):
uom = self.env.ref('uom.product_uom_hour', raise_if_not_found=False)
wtime = self.env.ref('uom.uom_categ_wtime')
if not uom:
uom = self.env['uom.uom'].search([('category_id', '=', wtime.id), ('uom_type', '=', 'reference')], limit=1)
if not uom:
uom = self.env['uom.uom'].search([('category_id', '=', wtime.id)], limit=1)
return uom
project_time_mode_id = fields.Many2one('uom.uom', string='Project Time Unit',
default=_default_project_time_mode_id,
help="This will set the unit of measure used in projects and tasks.\n"
"If you use the timesheet linked to projects, don't "
"forget to setup the right unit of measure in your employees.")
timesheet_encode_uom_id = fields.Many2one('uom.uom', string="Timesheet Encoding Unit",
default=_default_timesheet_encode_uom_id, domain=lambda self: [('category_id', '=', self.env.ref('uom.uom_categ_wtime').id)])
internal_project_id = fields.Many2one(
'project.project', string="Internal Project",
help="Default project value for timesheet generated from time off type.")
@api.constrains('internal_project_id')
def _check_internal_project_id_company(self):
if self.filtered(lambda company: company.internal_project_id and company.internal_project_id.sudo().company_id != company):
raise ValidationError(_('The Internal Project of a company should be in that company.'))
@api.model_create_multi
def create(self, values):
company = super(ResCompany, self).create(values)
# use sudo as the user could have the right to create a company
# but not to create a project. On the other hand, when the company
# is created, it is not in the allowed_company_ids on the env
company.sudo()._create_internal_project_task()
return company
def _create_internal_project_task(self):
results = []
type_ids = [(4, self.env.ref('hr_timesheet.internal_project_default_stage').id)]
for company in self:
company = company.with_company(company)
results += [{
'name': _('Internal'),
'allow_timesheets': True,
'company_id': company.id,
'type_ids': type_ids,
'task_ids': [(0, 0, {
'name': name,
'company_id': company.id,
}) for name in [_('Training'), _('Meeting')]]
}]
project_ids = self.env['project.project'].create(results)
projects_by_company = {project.company_id.id: project for project in project_ids}
for company in self:
company.internal_project_id = projects_by_company.get(company.id, False)
return project_ids
def _is_timesheet_hour_uom(self):
return self.timesheet_encode_uom_id and self.timesheet_encode_uom_id == self.env.ref('uom.product_uom_hour')
def _timesheet_uom_text(self):
return self._is_timesheet_hour_uom() and _("hours") or _("days")

View File

@ -0,0 +1,46 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
module_project_timesheet_holidays = fields.Boolean("Time Off",
compute="_compute_timesheet_modules", store=True, readonly=False)
reminder_user_allow = fields.Boolean(string="Employee Reminder")
reminder_allow = fields.Boolean(string="Approver Reminder")
project_time_mode_id = fields.Many2one(
'uom.uom', related='company_id.project_time_mode_id', string='Project Time Unit', readonly=False,
help="This will set the unit of measure used in projects and tasks.\n"
"If you use the timesheet linked to projects, don't "
"forget to setup the right unit of measure in your employees.")
is_encode_uom_days = fields.Boolean(compute='_compute_is_encode_uom_days')
timesheet_encode_method = fields.Selection([
('hours', 'Hours / Minutes'),
('days', 'Days / Half-Days'),
], string='Encoding Method', compute="_compute_timesheet_encode_method", inverse="_inverse_timesheet_encode_method", required=True)
@api.depends('company_id')
def _compute_timesheet_encode_method(self):
uom_day = self.env.ref('uom.product_uom_day', raise_if_not_found=False)
for settings in self:
settings.timesheet_encode_method = 'days' if settings.company_id.timesheet_encode_uom_id == uom_day else 'hours'
def _inverse_timesheet_encode_method(self):
uom_day = self.env.ref('uom.product_uom_day', raise_if_not_found=False)
uom_hour = self.env.ref('uom.product_uom_hour', raise_if_not_found=False)
for settings in self:
settings.company_id.timesheet_encode_uom_id = uom_day if settings.timesheet_encode_method == 'days' else uom_hour
@api.depends('timesheet_encode_method')
def _compute_is_encode_uom_days(self):
for settings in self:
settings.is_encode_uom_days = settings.timesheet_encode_method == 'days'
@api.depends('module_hr_timesheet')
def _compute_timesheet_modules(self):
self.filtered(lambda config: not config.module_hr_timesheet).update({
'module_project_timesheet_holidays': False,
})

19
models/uom_uom.py Normal file
View File

@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class Uom(models.Model):
_inherit = 'uom.uom'
def _unprotected_uom_xml_ids(self):
# Override
# When timesheet App is installed, we also need to protect the hour UoM
# from deletion (and warn in case of modification)
return [
"product_uom_dozen",
]
# widget used in the webclient when this unit is the one used to encode timesheets.
timesheet_widget = fields.Char("Widget")

1
populate/__init__.py Normal file
View File

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

73
populate/hr_timesheet.py Normal file
View File

@ -0,0 +1,73 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from collections import defaultdict
from dateutil.relativedelta import relativedelta
from odoo import models
from odoo.tools import populate
class AccountAnalyticLine(models.Model):
_inherit = "account.analytic.line"
_populate_sizes = {"small": 500, "medium": 5000, "large": 50000}
_populate_dependencies = ["project.project", "project.task", "hr.employee"]
def _populate_factories(self):
projects_groups = self.env['project.project']._read_group(
domain=[('id', 'in', self.env.registry.populated_models["project.project"])],
groupby=['company_id'],
aggregates=['id:array_agg'],
)
project_ids = []
projects_per_company = defaultdict(list)
for company, ids in projects_groups:
project_ids += ids
projects_per_company[company.id] = ids
tasks_per_project = {
project.id: ids
for project, ids in self.env['project.task']._read_group(
domain=[
('id', 'in', self.env.registry.populated_models["project.task"]),
('project_id', 'in', project_ids),
],
groupby=['project_id'],
aggregates=['id:array_agg'],
)
}
employees_per_company = {
company.id: ids
for company, ids in self.env['hr.employee']._read_group(
domain=[('id', 'in', self.env.registry.populated_models["hr.employee"])],
groupby=['company_id'],
aggregates=['id:array_agg'],
)
}
# Companies with projects and employees only
company_ids = list(
set(self.env.registry.populated_models["res.company"])\
& set(employees_per_company.keys())\
& set(projects_per_company.keys())
)
def get_company_id(random, **kwargs):
return random.choice(company_ids)
def get_project_id(random, **kwargs):
return random.choice(projects_per_company[kwargs['values']['company_id']])
def get_task_id(random, **kwargs):
task_ids = tasks_per_project[kwargs['values']['project_id']]
return random.choice(task_ids + [False] * (len(task_ids) // 3))
def get_employee_id(random, **kwargs):
return random.choice(employees_per_company[kwargs['values']['company_id']])
return [
("date", populate.randdatetime(relative_before=relativedelta(months=-3), relative_after=relativedelta(months=3))),
('unit_amount', populate.randfloat(0.0, 8.0)),
("company_id", populate.compute(get_company_id)),
("project_id", populate.compute(get_project_id)),
("task_id", populate.compute(get_task_id)),
("employee_id", populate.compute(get_employee_id)),
]

5
report/__init__.py Normal file
View File

@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import project_report
from . import timesheets_analysis_report

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