[IMP] hr_timesheet: Improve the usability of timesheet portal

1. Generic timesheets improvements

    - Portal /my/timesheets
    - Add a group bys task, date, employee
    - Add search in project, name, employee, task
        * The task column should not be visible if none of the timesheets have the Task field set
    - If a group by task, date, employee or project is applied, the corresponding column should not be visible

2. Timesheets from sub-tasks on parent tasks

    - Portal /my/tasks
    - Display the 'Total Hours' spent on a task at the end of the list of timesheets
    - Below the list of timesheets for the task, display another table detailing the respective timesheets (and total hours) of each sub-task linked to this task (= 1 table/list per sub-task)
        * Title should be: 'Timesheets for sub-task: xyz'
        * only visible if there are sub-tasks linked to this task
        * only visible if total hours > 0

3. Timesheets on Invoices

    - Portal /my/invoices
    - Add Project + task fields
        * Task field only visible if set on one of the timesheet entries
        * Project field only visible if there are at least 2 different projects in these timesheet entries

4. Correct visibility for the portal users
    - Portal users is allowed to see timesheets if the users are added in allowed portal users list in the project
    - When a portal user is added as the message follower of the project, the user is added to allowed portal users as well
    - When a portal user is removed from allowed portal users, the user is also removed from message followers

closes odoo/odoo#47248

Taskid: 2201908
Signed-off-by: Yannick Tivisse (yti) <yti@odoo.com>
This commit is contained in:
qho
2020-06-29 11:56:36 +00:00
committed by Yannick Tivisse
parent 78e2d2e2fb
commit 1424a3839a
12 changed files with 195 additions and 36 deletions
+49 -11
View File
@@ -4,11 +4,12 @@
from collections import OrderedDict
from dateutil.relativedelta import relativedelta
from operator import itemgetter
from datetime import datetime
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
from odoo.osv.expression import AND, OR
from odoo.addons.portal.controllers.portal import CustomerPortal, pager as portal_pager
@@ -22,23 +23,30 @@ class TimesheetCustomerPortal(CustomerPortal):
return values
@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='project', **kw):
def portal_my_timesheets(self, page=1, sortby=None, filterby=None, search=None, search_in='all', groupby='none', **kw):
Timesheet_sudo = request.env['account.analytic.line'].sudo()
values = self._prepare_portal_layout_values()
domain = request.env['account.analytic.line']._timesheet_get_portal_domain()
searchbar_sortings = {
'date': {'label': _('Newest'), 'order': 'date desc'},
'name': {'label': _('Name'), 'order': 'name'},
'name': {'label': _('Description'), 'order': 'name'},
}
searchbar_inputs = {
'all': {'input': 'all', 'label': _('Search in All')},
'project': {'input': 'project', 'label': _('Search in Project')},
'name': {'input': 'name', 'label': _('Search in Description')},
'employee': {'input': 'employee', 'label': _('Search in Employee')},
'task': {'input': 'task', 'label': _('Search in Task')},
}
searchbar_groupby = {
'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')},
}
today = fields.Date.today()
@@ -68,7 +76,16 @@ class TimesheetCustomerPortal(CustomerPortal):
domain = AND([domain, searchbar_filters[filterby]['domain']])
if search and search_in:
domain = AND([domain, [('name', 'ilike', 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)]])
domain += search_domain
timesheet_count = Timesheet_sudo.search_count(domain)
# pager
@@ -80,13 +97,34 @@ class TimesheetCustomerPortal(CustomerPortal):
step=self._items_per_page
)
if groupby == 'project':
order = "project_id, %s" % order
timesheets = Timesheet_sudo.search(domain, order=order, limit=self._items_per_page, offset=pager['offset'])
if groupby == 'project':
grouped_timesheets = [Timesheet_sudo.concat(*g) for k, g in groupbyelem(timesheets, itemgetter('project_id'))]
else:
grouped_timesheets = [timesheets]
def get_timesheets():
groupby_mapping = {
'project': 'project_id',
'task': 'task_id',
'employee': 'employee_id',
'date': 'date',
}
field = groupby_mapping.get(groupby, None)
orderby = '%s, %s' % (field, order) if field else order
timesheets = Timesheet_sudo.search(domain, order=orderby, limit=self._items_per_page, offset=pager['offset'])
if field:
if groupby == 'date':
time_data = Timesheet_sudo.read_group(domain, ['date', 'unit_amount:sum'], ['date:day'])
mapped_time = dict([(datetime.strptime(m['date:day'], '%d %b %Y').date(), m['unit_amount']) for m in time_data])
grouped_timesheets = [(Timesheet_sudo.concat(*g), mapped_time[k]) for k, g in groupbyelem(timesheets, itemgetter('date'))]
else:
time_data = time_data = Timesheet_sudo.read_group(domain, [field, 'unit_amount:sum'], [field])
mapped_time = dict([(m[field][0], m['unit_amount']) for m 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,
+9 -2
View File
@@ -1,6 +1,7 @@
# -*- 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
@@ -12,7 +13,13 @@ class ProjectCustomerPortal(CustomerPortal):
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()
domain = expression.AND([domain, [('task_id', '=', task.id)]])
timesheets = request.env['account.analytic.line'].sudo().search(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['timesheets'] = timesheets
values['timesheets_by_subtask'] = timesheets_by_subtask
return values
+6 -6
View File
@@ -126,12 +126,12 @@ class AccountAnalyticLine(models.Model):
return etree.tostring(doc, encoding='unicode')
def _timesheet_get_portal_domain(self):
return ['|', '&',
('task_id.project_id.privacy_visibility', '=', 'portal'),
('task_id.project_id.message_partner_ids', 'child_of', [self.env.user.partner_id.commercial_partner_id.id]),
'&',
('task_id.project_id.privacy_visibility', '=', 'portal'),
('task_id.message_partner_ids', 'child_of', [self.env.user.partner_id.commercial_partner_id.id])]
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.allowed_portal_user_ids', 'child_of', [self.env.user.id]),
('task_id.project_id.privacy_visibility', '=', 'portal')]
def _timesheet_preprocess(self, vals):
""" Deduce other field values from the one given.
@@ -33,24 +33,45 @@
</t>
<t t-if="grouped_timesheets">
<t t-call="portal.portal_table">
<t t-foreach="grouped_timesheets" t-as="timesheets">
<t t-foreach="grouped_timesheets" t-as="timesheets_with_hours">
<t t-set="timesheets" t-value="timesheets_with_hours[0]"/>
<t t-set="hours_spent" t-value="timesheets_with_hours[1]"/>
<thead>
<tr t-attf-class="{{'thead-light' if not groupby == 'none' else ''}}">
<th t-if="groupby == 'none'">Description</th>
<th t-else="">
<tr t-if="not groupby =='none'" t-attf-class="{{'thead-light'}}">
<th t-if="groupby == 'project'" colspan="5">
<em class="font-weight-normal text-muted">Timesheets for project:</em>
<span t-field="timesheets[0].project_id.name"/></th>
<th>Date</th>
<th>Employee</th>
<th class="text-right">Duration</th>
<span t-field="timesheets[0].project_id.name"/>
</th>
<th t-elif="groupby == 'task'" colspan="5">
<em class="font-weight-normal text-muted">Timesheets for task:</em>
<span t-field="timesheets[0].task_id.name"/>
</th>
<th t-elif="groupby == 'date'" colspan="5">
<em class="font-weight-normal text-muted">Timesheets on </em>
<span t-field="timesheets[0].date"/>
</th>
<th t-elif="groupby == 'employee'" colspan="5">
<em class="font-weight-normal text-muted">Timesheets for employee:</em>
<span t-field="timesheets[0].employee_id.name"/>
</th>
</tr>
<tr>
<th t-if="not groupby == 'date'">Date</th>
<th t-if="not groupby == 'employee'">Employee</th>
<th t-if="not groupby == 'project'">Project</th>
<th t-if="not groupby == 'task'">Task</th>
<th>Description</th>
<th class="text-right">Hours Spent <small class="text-muted">(Total: <span t-esc="hours_spent" t-options='{"widget": "float_time"}'/>)</small></th>
</tr>
</thead>
<tbody>
<t t-foreach="timesheets" t-as="timesheet">
<tr>
<td t-if="not groupby == 'date'"><span t-field="timesheet.date" t-options='{"widget": "date"}'/></td>
<td t-if="not groupby == 'employee'"><span t-field="timesheet.employee_id"/></td>
<td t-if="not groupby == 'project'"><span t-field="timesheet.project_id"/></td>
<td t-if="not groupby == 'task'"><span t-field="timesheet.task_id"/></td>
<td><span t-esc="timesheet.name"/></td>
<td><span t-field="timesheet.date" t-options='{"widget": "date"}'/></td>
<td><span t-field="timesheet.employee_id"/></td>
<td class="text-right"><span t-field="timesheet.unit_amount" t-options='{"widget": "float_time"}'/></td>
</tr>
</t>
@@ -8,6 +8,9 @@
<h5 class="mt-2 mb-2">Timesheets</h5>
<t t-call="hr_timesheet.portal_timesheet_table"/>
</div>
<t t-if="timesheets_by_subtask">
<t t-call="hr_timesheet.portal_subtask_timesheet_tables"/>
</t>
</xpath>
</template>
@@ -16,18 +19,58 @@
<thead>
<tr>
<th>Date</th>
<th>Description</th>
<th>Employee</th>
<th>Description</th>
<th class="text-right">Duration</th>
</tr>
</thead>
<tr t-foreach="timesheets" t-as="timesheet">
<td><t t-esc="timesheet.date" t-options='{"widget": "date"}'/></td>
<td><t t-esc="timesheet.name"/></td>
<td><t t-esc="timesheet.employee_id.name"/></td>
<td><t t-esc="timesheet.name"/></td>
<td class="text-right"><span t-field="timesheet.unit_amount" t-options='{"widget": "float_time"}'/></td>
</tr>
<tfoot>
<tr>
<th colspan="3"></th>
<th class="text-right">Total Hours: <span t-esc="round(sum(timesheets.mapped('unit_amount')), 2)" t-options='{"widget": "float_time"}'/></th>
</tr>
</tfoot>
</table>
</template>
<template id="portal_subtask_timesheet_tables" name="Portal Subtask Timesheet Tables">
<t t-foreach="timesheets_by_subtask.values()" t-as="timesheets">
<t t-set="total_hours" t-value="round(sum(timesheets.mapped('unit_amount')), 2)"/>
<t t-if="total_hours &gt; 0">
<div class="container">
<hr class="mt-4 mb-1"/>
<h5 class="mt-2 mb-2">Timesheets for sub-task: <t t-esc="timesheets[0].task_id.name" /></h5>
<table class="table table-sm">
<thead>
<tr>
<th>Date</th>
<th>Employee</th>
<th>Description</th>
<th class="text-right">Duration</th>
</tr>
</thead>
<tr t-foreach="timesheets" t-as="timesheet">
<td><t t-esc="timesheet.date" t-options='{"widget": "date"}'/></td>
<td><t t-esc="timesheet.employee_id.name"/></td>
<td><t t-esc="timesheet.name"/></td>
<td class="text-right"><span t-field="timesheet.unit_amount" t-options='{"widget": "float_time"}'/></td>
</tr>
<tfoot>
<tr>
<th class="text-right" colspan="3"></th>
<th class="text-right">Total Hours: <span t-esc="total_hours" t-options='{"widget": "float_time"}'/></th>
</tr>
</tfoot>
</table>
</div>
</t>
</t>
</template>
</odoo>
+2
View File
@@ -9,12 +9,14 @@ BasicView.include({
init: function () {
this._super.apply(this, arguments);
const post_refresh = this._getFieldOption('message_ids', 'post_refresh', false);
const followers_post_refresh = this._getFieldOption('message_follower_ids', 'post_refresh', false);
this.chatterFields = {
hasActivityIds: this._hasField('activity_ids'),
hasMessageFollowerIds: this._hasField('message_follower_ids'),
hasMessageIds: this._hasField('message_ids'),
hasRecordReloadOnAttachmentsChanged: post_refresh === 'always',
hasRecordReloadOnMessagePosted: !!post_refresh,
hasRecordReloadOnFollowersUpdate: !!followers_post_refresh,
isActivityBoxVisible: this._getFieldOption('message_ids', 'open_attachments', false),
};
const fieldsInfo = this.fieldsInfo[this.viewType];
@@ -1045,6 +1045,7 @@ function factory(dependencies) {
* @param {boolean} [param0.mail_invite_follower_channel_only=false]
*/
_promptAddFollower({ mail_invite_follower_channel_only = false } = {}) {
const self = this;
const action = {
type: 'ir.actions.act_window',
res_model: 'mail.wizard.invite',
@@ -1061,7 +1062,10 @@ function factory(dependencies) {
this.env.bus.trigger('do-action', {
action,
options: {
on_close: () => this.refreshFollowers(),
on_close: async () => {
await this.async(() => this.refreshFollowers());
this.env.bus.trigger('mail.thread:promptAddFollower-closed');
},
},
});
}
@@ -40,6 +40,7 @@ FormRenderer.include({
this.off('o_attachments_changed', this);
this.off('o_chatter_rendered', this);
this.off('o_message_posted', this);
owl.Component.env.bus.off('mail.thread:promptAddFollower-closed', this);
},
//--------------------------------------------------------------------------
@@ -75,6 +76,9 @@ FormRenderer.include({
if (this.chatterFields.hasRecordReloadOnAttachmentsChanged) {
this.on('o_attachments_changed', this, ev => this.trigger_up('reload'));
}
if (this.chatterFields.hasRecordReloadOnFollowersUpdate) {
owl.Component.env.bus.on('mail.thread:promptAddFollower-closed', this, ev => this.trigger_up('reload'));
}
},
/**
* @private
+19 -1
View File
@@ -341,6 +341,8 @@ class Project(models.Model):
if allowed_users_changed:
for project in self:
permission_removed = allowed_users.get(project) - project.allowed_user_ids
allowed_portal_users_removed = permission_removed.filtered('share')
project.message_unsubscribe(allowed_portal_users_removed.partner_id.commercial_partner_id.ids)
for task in project.task_ids:
task.allowed_user_ids -= permission_removed
@@ -384,13 +386,18 @@ class Project(models.Model):
return result
def message_subscribe(self, partner_ids=None, channel_ids=None, subtype_ids=None):
""" Subscribe to all existing active tasks when subscribing to a project """
"""
Subscribe to all existing active tasks when subscribing to a project
And add the portal user subscribed to allowed portal users
"""
res = super(Project, self).message_subscribe(partner_ids=partner_ids, channel_ids=channel_ids, subtype_ids=subtype_ids)
project_subtypes = self.env['mail.message.subtype'].browse(subtype_ids) if subtype_ids else None
task_subtypes = project_subtypes.mapped('parent_id').ids if project_subtypes else None
if not subtype_ids or task_subtypes:
self.mapped('tasks').message_subscribe(
partner_ids=partner_ids, channel_ids=channel_ids, subtype_ids=task_subtypes)
if partner_ids:
self.allowed_portal_user_ids |= self.env['res.partner'].browse(partner_ids).user_ids.filtered('share')
return res
def message_unsubscribe(self, partner_ids=None, channel_ids=None):
@@ -780,6 +787,17 @@ class Task(models.Model):
)
return super(Task, self).get_empty_list_help(help)
def message_subscribe(self, partner_ids=None, channel_ids=None, subtype_ids=None):
"""
Add the users subscribed to allowed portal users
"""
res = super(Task, self).message_subscribe(partner_ids=partner_ids, channel_ids=channel_ids, subtype_ids=subtype_ids)
if partner_ids:
new_allowed_users = self.env['res.partner'].browse(partner_ids).user_ids.filtered('share')
tasks = self.filtered(lambda task: task.project_id.privacy_visibility == 'portal')
tasks.write({'allowed_user_ids': [(4, user.id) for user in new_allowed_users]})
return res
# ----------------------------------------
# Case management
# ----------------------------------------
+2 -2
View File
@@ -357,7 +357,7 @@
</page>
</notebook>
<div class="oe_chatter">
<field name="message_follower_ids" help="Follow this project to automatically track the events associated to tasks and issues of this project." groups="base.group_user"/>
<field name="message_follower_ids" options="{'post_refresh':True}" help="Follow this project to automatically track the events associated to tasks and issues of this project." groups="base.group_user"/>
</div>
</sheet>
</form>
@@ -695,7 +695,7 @@
</notebook>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids" groups="base.group_user"/>
<field name="message_follower_ids" options="{'post_refresh':True}" groups="base.group_user"/>
<field name="activity_ids"/>
<field name="message_ids"/>
</div>
+1
View File
@@ -66,6 +66,7 @@ class SaleOrder(models.Model):
if len(task_projects) == 1 and len(self.tasks_ids) > 1: # redirect to task of the project (with kanban stage, ...)
action = self.with_context(active_id=task_projects.id).env.ref(
'project.act_project_project_2_project_task_all').read()[0]
action['domain'] = [('project_id', '=', task_projects.id)]
if action.get('context'):
eval_context = self.env['ir.actions.actions']._get_eval_context()
eval_context.update({'active_id': task_projects.id})
@@ -26,7 +26,28 @@
</h5>
</div>
<div id="collapseTimesheet" class="card-body show" data-parent="#accordion">
<t t-call="hr_timesheet.portal_timesheet_table"/>
<t t-set="nr_tasks" t-value="len(timesheets.mapped('task_id'))"/>
<t t-set="nr_projects" t-value="len(timesheets.mapped('project_id'))"/>
<table class="table table-sm">
<thead>
<tr>
<th>Date</th>
<th>Employee</th>
<th t-if="nr_projects &gt; 1">Project</th>
<th t-if="nr_tasks &gt; 0">Task</th>
<th>Description</th>
<th class="text-right">Duration</th>
</tr>
</thead>
<tr t-foreach="timesheets" t-as="timesheet">
<td><t t-esc="timesheet.date" t-options='{"widget": "date"}'/></td>
<td><t t-esc="timesheet.employee_id.name"/></td>
<td t-if="nr_projects &gt; 1"><span t-field="timesheet.project_id"/></td>
<td t-if="nr_tasks &gt; 0"><span t-field="timesheet.task_id"/></td>
<td><t t-esc="timesheet.name"/></td>
<td class="text-right"><span t-field="timesheet.unit_amount" t-options='{"widget": "float_time"}'/></td>
</tr>
</table>
</div>
</div>
</div>