From 1424a3839a6c304599bdeefcd74f29279ae75420 Mon Sep 17 00:00:00 2001 From: qho Date: Tue, 10 Mar 2020 07:50:27 +0000 Subject: [PATCH] [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) --- addons/hr_timesheet/controllers/portal.py | 60 +++++++++++++++---- addons/hr_timesheet/controllers/project.py | 11 +++- addons/hr_timesheet/models/hr_timesheet.py | 12 ++-- .../views/hr_timesheet_portal_templates.xml | 41 +++++++++---- .../views/project_portal_templates.xml | 47 ++++++++++++++- addons/mail/static/src/js/basic_view.js | 2 + .../mail/static/src/models/thread/thread.js | 6 +- .../widgets/form_renderer/form_renderer.js | 4 ++ addons/project/models/project.py | 20 ++++++- addons/project/views/project_views.xml | 4 +- addons/sale_project/models/sale_order.py | 1 + .../views/sale_timesheet_portal_templates.xml | 23 ++++++- 12 files changed, 195 insertions(+), 36 deletions(-) diff --git a/addons/hr_timesheet/controllers/portal.py b/addons/hr_timesheet/controllers/portal.py index 95646b94dcd..aceb00e3232 100644 --- a/addons/hr_timesheet/controllers/portal.py +++ b/addons/hr_timesheet/controllers/portal.py @@ -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/'], 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, diff --git a/addons/hr_timesheet/controllers/project.py b/addons/hr_timesheet/controllers/project.py index bc321ab98fa..dd377160d6b 100644 --- a/addons/hr_timesheet/controllers/project.py +++ b/addons/hr_timesheet/controllers/project.py @@ -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 diff --git a/addons/hr_timesheet/models/hr_timesheet.py b/addons/hr_timesheet/models/hr_timesheet.py index f8001201a57..4d55d5942ff 100644 --- a/addons/hr_timesheet/models/hr_timesheet.py +++ b/addons/hr_timesheet/models/hr_timesheet.py @@ -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. diff --git a/addons/hr_timesheet/views/hr_timesheet_portal_templates.xml b/addons/hr_timesheet/views/hr_timesheet_portal_templates.xml index cc0ec4d8f04..3d0044a2ea9 100644 --- a/addons/hr_timesheet/views/hr_timesheet_portal_templates.xml +++ b/addons/hr_timesheet/views/hr_timesheet_portal_templates.xml @@ -33,24 +33,45 @@ - + + + - - Description - + + Timesheets for project: - - Date - Employee - Duration + + + + Timesheets for task: + + + + Timesheets on + + + + Timesheets for employee: + + + + + Date + Employee + Project + Task + Description + Hours Spent (Total: ) + + + + - - diff --git a/addons/hr_timesheet/views/project_portal_templates.xml b/addons/hr_timesheet/views/project_portal_templates.xml index 0d248cd1eed..19c0648db03 100644 --- a/addons/hr_timesheet/views/project_portal_templates.xml +++ b/addons/hr_timesheet/views/project_portal_templates.xml @@ -8,6 +8,9 @@
Timesheets
+ + + @@ -16,18 +19,58 @@ Date - Description Employee + Description Duration - + + + + + Total Hours: + + + + diff --git a/addons/mail/static/src/js/basic_view.js b/addons/mail/static/src/js/basic_view.js index 042e0ded8e8..6d74903e19c 100644 --- a/addons/mail/static/src/js/basic_view.js +++ b/addons/mail/static/src/js/basic_view.js @@ -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]; diff --git a/addons/mail/static/src/models/thread/thread.js b/addons/mail/static/src/models/thread/thread.js index 2267d170f10..9bedc8096bc 100644 --- a/addons/mail/static/src/models/thread/thread.js +++ b/addons/mail/static/src/models/thread/thread.js @@ -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'); + }, }, }); } diff --git a/addons/mail/static/src/widgets/form_renderer/form_renderer.js b/addons/mail/static/src/widgets/form_renderer/form_renderer.js index df9c8b90d25..52f2d962ba2 100644 --- a/addons/mail/static/src/widgets/form_renderer/form_renderer.js +++ b/addons/mail/static/src/widgets/form_renderer/form_renderer.js @@ -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 diff --git a/addons/project/models/project.py b/addons/project/models/project.py index 4ab3f1380b3..498aa9a96f3 100644 --- a/addons/project/models/project.py +++ b/addons/project/models/project.py @@ -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 # ---------------------------------------- diff --git a/addons/project/views/project_views.xml b/addons/project/views/project_views.xml index f452d6fb870..a3902381fa4 100644 --- a/addons/project/views/project_views.xml +++ b/addons/project/views/project_views.xml @@ -357,7 +357,7 @@
- +
@@ -695,7 +695,7 @@
- +
diff --git a/addons/sale_project/models/sale_order.py b/addons/sale_project/models/sale_order.py index c4d5868918a..44885e0b71a 100644 --- a/addons/sale_project/models/sale_order.py +++ b/addons/sale_project/models/sale_order.py @@ -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}) diff --git a/addons/sale_timesheet/views/sale_timesheet_portal_templates.xml b/addons/sale_timesheet/views/sale_timesheet_portal_templates.xml index e5127f88fea..d7ade138475 100644 --- a/addons/sale_timesheet/views/sale_timesheet_portal_templates.xml +++ b/addons/sale_timesheet/views/sale_timesheet_portal_templates.xml @@ -26,7 +26,28 @@
- + + + + + + + + + + + + + + + + + + + + + +
DateEmployeeProjectTaskDescriptionDuration