Начальное наполнение
This commit is contained in:
parent
24b2b15006
commit
5c5842780d
50
__init__.py
Normal file
50
__init__.py
Normal 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
66
__manifest__.py
Normal 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
5
controllers/__init__.py
Normal 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
185
controllers/portal.py
Normal 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
59
controllers/project.py
Normal 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
17
data/digest_data.xml
Normal 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>
|
25
data/hr_timesheet_data.xml
Normal file
25
data/hr_timesheet_data.xml
Normal 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
3464
data/hr_timesheet_demo.xml
Normal file
File diff suppressed because it is too large
Load Diff
1381
i18n/af.po
Normal file
1381
i18n/af.po
Normal file
File diff suppressed because it is too large
Load Diff
1377
i18n/am.po
Normal file
1377
i18n/am.po
Normal file
File diff suppressed because it is too large
Load Diff
1559
i18n/ar.po
Normal file
1559
i18n/ar.po
Normal file
File diff suppressed because it is too large
Load Diff
1384
i18n/az.po
Normal file
1384
i18n/az.po
Normal file
File diff suppressed because it is too large
Load Diff
1513
i18n/bg.po
Normal file
1513
i18n/bg.po
Normal file
File diff suppressed because it is too large
Load Diff
1382
i18n/bs.po
Normal file
1382
i18n/bs.po
Normal file
File diff suppressed because it is too large
Load Diff
1556
i18n/ca.po
Normal file
1556
i18n/ca.po
Normal file
File diff suppressed because it is too large
Load Diff
1536
i18n/cs.po
Normal file
1536
i18n/cs.po
Normal file
File diff suppressed because it is too large
Load Diff
1530
i18n/da.po
Normal file
1530
i18n/da.po
Normal file
File diff suppressed because it is too large
Load Diff
1581
i18n/de.po
Normal file
1581
i18n/de.po
Normal file
File diff suppressed because it is too large
Load Diff
1384
i18n/el.po
Normal file
1384
i18n/el.po
Normal file
File diff suppressed because it is too large
Load Diff
1379
i18n/en_AU.po
Normal file
1379
i18n/en_AU.po
Normal file
File diff suppressed because it is too large
Load Diff
1380
i18n/en_GB.po
Normal file
1380
i18n/en_GB.po
Normal file
File diff suppressed because it is too large
Load Diff
1574
i18n/es.po
Normal file
1574
i18n/es.po
Normal file
File diff suppressed because it is too large
Load Diff
1575
i18n/es_419.po
Normal file
1575
i18n/es_419.po
Normal file
File diff suppressed because it is too large
Load Diff
1380
i18n/es_BO.po
Normal file
1380
i18n/es_BO.po
Normal file
File diff suppressed because it is too large
Load Diff
1380
i18n/es_CL.po
Normal file
1380
i18n/es_CL.po
Normal file
File diff suppressed because it is too large
Load Diff
1380
i18n/es_CO.po
Normal file
1380
i18n/es_CO.po
Normal file
File diff suppressed because it is too large
Load Diff
1380
i18n/es_CR.po
Normal file
1380
i18n/es_CR.po
Normal file
File diff suppressed because it is too large
Load Diff
1380
i18n/es_DO.po
Normal file
1380
i18n/es_DO.po
Normal file
File diff suppressed because it is too large
Load Diff
1380
i18n/es_EC.po
Normal file
1380
i18n/es_EC.po
Normal file
File diff suppressed because it is too large
Load Diff
1380
i18n/es_PE.po
Normal file
1380
i18n/es_PE.po
Normal file
File diff suppressed because it is too large
Load Diff
1380
i18n/es_PY.po
Normal file
1380
i18n/es_PY.po
Normal file
File diff suppressed because it is too large
Load Diff
1380
i18n/es_VE.po
Normal file
1380
i18n/es_VE.po
Normal file
File diff suppressed because it is too large
Load Diff
1560
i18n/et.po
Normal file
1560
i18n/et.po
Normal file
File diff suppressed because it is too large
Load Diff
1380
i18n/eu.po
Normal file
1380
i18n/eu.po
Normal file
File diff suppressed because it is too large
Load Diff
1527
i18n/fa.po
Normal file
1527
i18n/fa.po
Normal file
File diff suppressed because it is too large
Load Diff
1578
i18n/fi.po
Normal file
1578
i18n/fi.po
Normal file
File diff suppressed because it is too large
Load Diff
1380
i18n/fo.po
Normal file
1380
i18n/fo.po
Normal file
File diff suppressed because it is too large
Load Diff
1582
i18n/fr.po
Normal file
1582
i18n/fr.po
Normal file
File diff suppressed because it is too large
Load Diff
1379
i18n/fr_BE.po
Normal file
1379
i18n/fr_BE.po
Normal file
File diff suppressed because it is too large
Load Diff
1380
i18n/fr_CA.po
Normal file
1380
i18n/fr_CA.po
Normal file
File diff suppressed because it is too large
Load Diff
1380
i18n/gl.po
Normal file
1380
i18n/gl.po
Normal file
File diff suppressed because it is too large
Load Diff
1381
i18n/gu.po
Normal file
1381
i18n/gu.po
Normal file
File diff suppressed because it is too large
Load Diff
1523
i18n/he.po
Normal file
1523
i18n/he.po
Normal file
File diff suppressed because it is too large
Load Diff
1379
i18n/hi.po
Normal file
1379
i18n/hi.po
Normal file
File diff suppressed because it is too large
Load Diff
1392
i18n/hr.po
Normal file
1392
i18n/hr.po
Normal file
File diff suppressed because it is too large
Load Diff
1498
i18n/hr_timesheet.pot
Normal file
1498
i18n/hr_timesheet.pot
Normal file
File diff suppressed because it is too large
Load Diff
1505
i18n/hu.po
Normal file
1505
i18n/hu.po
Normal file
File diff suppressed because it is too large
Load Diff
1379
i18n/hy.po
Normal file
1379
i18n/hy.po
Normal file
File diff suppressed because it is too large
Load Diff
1575
i18n/id.po
Normal file
1575
i18n/id.po
Normal file
File diff suppressed because it is too large
Load Diff
1383
i18n/is.po
Normal file
1383
i18n/is.po
Normal file
File diff suppressed because it is too large
Load Diff
1576
i18n/it.po
Normal file
1576
i18n/it.po
Normal file
File diff suppressed because it is too large
Load Diff
1540
i18n/ja.po
Normal file
1540
i18n/ja.po
Normal file
File diff suppressed because it is too large
Load Diff
1380
i18n/ka.po
Normal file
1380
i18n/ka.po
Normal file
File diff suppressed because it is too large
Load Diff
1380
i18n/kab.po
Normal file
1380
i18n/kab.po
Normal file
File diff suppressed because it is too large
Load Diff
1379
i18n/kk.po
Normal file
1379
i18n/kk.po
Normal file
File diff suppressed because it is too large
Load Diff
1382
i18n/km.po
Normal file
1382
i18n/km.po
Normal file
File diff suppressed because it is too large
Load Diff
1544
i18n/ko.po
Normal file
1544
i18n/ko.po
Normal file
File diff suppressed because it is too large
Load Diff
1381
i18n/lb.po
Normal file
1381
i18n/lb.po
Normal file
File diff suppressed because it is too large
Load Diff
1380
i18n/lo.po
Normal file
1380
i18n/lo.po
Normal file
File diff suppressed because it is too large
Load Diff
1512
i18n/lt.po
Normal file
1512
i18n/lt.po
Normal file
File diff suppressed because it is too large
Load Diff
1510
i18n/lv.po
Normal file
1510
i18n/lv.po
Normal file
File diff suppressed because it is too large
Load Diff
1380
i18n/mk.po
Normal file
1380
i18n/mk.po
Normal file
File diff suppressed because it is too large
Load Diff
1391
i18n/mn.po
Normal file
1391
i18n/mn.po
Normal file
File diff suppressed because it is too large
Load Diff
1385
i18n/nb.po
Normal file
1385
i18n/nb.po
Normal file
File diff suppressed because it is too large
Load Diff
1377
i18n/ne.po
Normal file
1377
i18n/ne.po
Normal file
File diff suppressed because it is too large
Load Diff
1569
i18n/nl.po
Normal file
1569
i18n/nl.po
Normal file
File diff suppressed because it is too large
Load Diff
1554
i18n/pl.po
Normal file
1554
i18n/pl.po
Normal file
File diff suppressed because it is too large
Load Diff
1506
i18n/pt.po
Normal file
1506
i18n/pt.po
Normal file
File diff suppressed because it is too large
Load Diff
1575
i18n/pt_BR.po
Normal file
1575
i18n/pt_BR.po
Normal file
File diff suppressed because it is too large
Load Diff
1396
i18n/ro.po
Normal file
1396
i18n/ro.po
Normal file
File diff suppressed because it is too large
Load Diff
1595
i18n/ru.po
Normal file
1595
i18n/ru.po
Normal file
File diff suppressed because it is too large
Load Diff
1527
i18n/sk.po
Normal file
1527
i18n/sk.po
Normal file
File diff suppressed because it is too large
Load Diff
1512
i18n/sl.po
Normal file
1512
i18n/sl.po
Normal file
File diff suppressed because it is too large
Load Diff
1380
i18n/sq.po
Normal file
1380
i18n/sq.po
Normal file
File diff suppressed because it is too large
Load Diff
1534
i18n/sr.po
Normal file
1534
i18n/sr.po
Normal file
File diff suppressed because it is too large
Load Diff
1383
i18n/sr@latin.po
Normal file
1383
i18n/sr@latin.po
Normal file
File diff suppressed because it is too large
Load Diff
1523
i18n/sv.po
Normal file
1523
i18n/sv.po
Normal file
File diff suppressed because it is too large
Load Diff
1380
i18n/ta.po
Normal file
1380
i18n/ta.po
Normal file
File diff suppressed because it is too large
Load Diff
1379
i18n/te.po
Normal file
1379
i18n/te.po
Normal file
File diff suppressed because it is too large
Load Diff
1562
i18n/th.po
Normal file
1562
i18n/th.po
Normal file
File diff suppressed because it is too large
Load Diff
1550
i18n/tr.po
Normal file
1550
i18n/tr.po
Normal file
File diff suppressed because it is too large
Load Diff
1567
i18n/uk.po
Normal file
1567
i18n/uk.po
Normal file
File diff suppressed because it is too large
Load Diff
1563
i18n/vi.po
Normal file
1563
i18n/vi.po
Normal file
File diff suppressed because it is too large
Load Diff
1537
i18n/zh_CN.po
Normal file
1537
i18n/zh_CN.po
Normal file
File diff suppressed because it is too large
Load Diff
1537
i18n/zh_TW.po
Normal file
1537
i18n/zh_TW.po
Normal file
File diff suppressed because it is too large
Load Diff
14
models/__init__.py
Normal file
14
models/__init__.py
Normal 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
70
models/hr_employee.py
Normal 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
449
models/hr_timesheet.py
Normal 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
43
models/ir_http.py
Normal 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
14
models/ir_ui_menu.py
Normal 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
|
21
models/project_collaborator.py
Normal file
21
models/project_collaborator.py
Normal 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
285
models/project_project.py
Normal 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
248
models/project_task.py
Normal 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
39
models/project_update.py
Normal 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
81
models/res_company.py
Normal 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")
|
46
models/res_config_settings.py
Normal file
46
models/res_config_settings.py
Normal 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
19
models/uom_uom.py
Normal 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
1
populate/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
from . import hr_timesheet
|
73
populate/hr_timesheet.py
Normal file
73
populate/hr_timesheet.py
Normal 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
5
report/__init__.py
Normal 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
Loading…
x
Reference in New Issue
Block a user