diff --git a/addons/account/controllers/portal.py b/addons/account/controllers/portal.py index 06f758f3cf1..1d9e6294767 100644 --- a/addons/account/controllers/portal.py +++ b/addons/account/controllers/portal.py @@ -2,6 +2,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo import http, _ +from odoo.osv import expression from odoo.addons.portal.controllers.portal import CustomerPortal, pager as portal_pager from odoo.exceptions import AccessError, MissingError from collections import OrderedDict @@ -34,10 +35,29 @@ class PortalAccount(CustomerPortal): @http.route(['/my/invoices', '/my/invoices/page/'], type='http', auth="user", website=True) def portal_my_invoices(self, page=1, date_begin=None, date_end=None, sortby=None, filterby=None, **kw): + values = self._prepare_my_invoices_values(page, date_begin, date_end, sortby, filterby) + + # pager + pager = portal_pager(**values['pager']) + + # content according to pager and archive selected + invoices = values['invoices'](pager['offset']) + request.session['my_invoices_history'] = invoices.ids[:100] + + values.update({ + 'invoices': invoices, + 'pager': pager, + }) + return request.render("account.portal_my_invoices", values) + + def _prepare_my_invoices_values(self, page, date_begin, date_end, sortby, filterby, domain=None, url="/my/invoices"): values = self._prepare_portal_layout_values() AccountInvoice = request.env['account.move'] - domain = self._get_invoices_domain() + domain = expression.AND([ + domain or [], + self._get_invoices_domain(), + ]) searchbar_sortings = { 'date': {'label': _('Date'), 'order': 'invoice_date desc'}, @@ -63,32 +83,26 @@ class PortalAccount(CustomerPortal): if date_begin and date_end: domain += [('create_date', '>', date_begin), ('create_date', '<=', date_end)] - # count for pager - invoice_count = AccountInvoice.search_count(domain) - # pager - pager = portal_pager( - url="/my/invoices", - url_args={'date_begin': date_begin, 'date_end': date_end, 'sortby': sortby}, - total=invoice_count, - page=page, - step=self._items_per_page - ) - # content according to pager and archive selected - invoices = AccountInvoice.search(domain, order=order, limit=self._items_per_page, offset=pager['offset']) - request.session['my_invoices_history'] = invoices.ids[:100] - values.update({ 'date': date_begin, - 'invoices': invoices, + # content according to pager and archive selected + # lambda function to get the invoices recordset when the pager will be defined in the main method of a route + 'invoices': lambda pager_offset: AccountInvoice.search(domain, order=order, limit=self._items_per_page, offset=pager_offset), 'page_name': 'invoice', - 'pager': pager, - 'default_url': '/my/invoices', + 'pager': { # vals to define the pager. + "url": url, + "url_args": {'date_begin': date_begin, 'date_end': date_end, 'sortby': sortby}, + "total": AccountInvoice.search_count(domain), + "page": page, + "step": self._items_per_page, + }, + 'default_url': url, 'searchbar_sortings': searchbar_sortings, 'sortby': sortby, 'searchbar_filters': OrderedDict(sorted(searchbar_filters.items())), - 'filterby':filterby, + 'filterby': filterby, }) - return request.render("account.portal_my_invoices", values) + return values @http.route(['/my/invoices/'], type='http', auth="public", website=True) def portal_my_invoice_detail(self, invoice_id, access_token=None, report_type=None, download=False, **kw): diff --git a/addons/hr_timesheet/controllers/project.py b/addons/hr_timesheet/controllers/project.py index 970ae2fdb0f..102b6cfe530 100644 --- a/addons/hr_timesheet/controllers/project.py +++ b/addons/hr_timesheet/controllers/project.py @@ -10,8 +10,8 @@ from odoo.addons.project.controllers.portal import CustomerPortal class ProjectCustomerPortal(CustomerPortal): - def _prepare_project_sharing_session_info(self, project): - session_info = super()._prepare_project_sharing_session_info(project) + def _prepare_project_sharing_session_info(self, project, task=None): + session_info = super()._prepare_project_sharing_session_info(project, task) company = project.company_id timesheet_encode_uom = company.timesheet_encode_uom_id diff --git a/addons/hr_timesheet/views/project_sharing_views.xml b/addons/hr_timesheet/views/project_sharing_views.xml index 2ca497a296e..e380958599c 100644 --- a/addons/hr_timesheet/views/project_sharing_views.xml +++ b/addons/hr_timesheet/views/project_sharing_views.xml @@ -161,15 +161,4 @@ - - project.sharing.inherit.project.task.view.search - project.task - - - - - - - - diff --git a/addons/mail/static/src/js/m2x_avatar_user.js b/addons/mail/static/src/js/m2x_avatar_user.js index 6dca143b4f2..ffa2f7f8751 100644 --- a/addons/mail/static/src/js/m2x_avatar_user.js +++ b/addons/mail/static/src/js/m2x_avatar_user.js @@ -31,7 +31,6 @@ const M2XAvatarMixin = { throw new Error(`This widget is only supported on many2one and many2many fields pointing to ${JSON.stringify(this.supportedModels)}`); } this.className = `${this.className || ''} o_clickable_m2x_avatar`.trim(); - this.noOpenChat = this.nodeOptions.no_open_chat || false; }, //-------------------------------------------------------------------------- @@ -46,11 +45,8 @@ const M2XAvatarMixin = { * @returns {Promise} */ async _openChat(params) { - if (!this.noOpenChat) { - const messaging = await Component.env.services.messaging.get(); - return messaging.openChat(params); - } - return Promise.resolve(); + const messaging = await Component.env.services.messaging.get(); + return messaging.openChat(params); }, }; @@ -189,7 +185,7 @@ export const Many2OneAvatarUser = Many2OneAvatar.extend(M2XAvatarMixin, { _onAvatarClicked(ev) { ev.stopPropagation(); // in list view, prevent from opening the record this._openChat({ userId: this.value.res_id }); - }, + } }); export const KanbanMany2OneAvatarUser = Many2OneAvatarUser.extend({ diff --git a/addons/mail/static/tests/m2x_avatar_user_tests.js b/addons/mail/static/tests/m2x_avatar_user_tests.js index 78829683bbb..bfdfa6e9183 100644 --- a/addons/mail/static/tests/m2x_avatar_user_tests.js +++ b/addons/mail/static/tests/m2x_avatar_user_tests.js @@ -313,110 +313,6 @@ QUnit.module('mail', {}, function () { kanban.destroy(); }); - QUnit.test('many2one_avatar_user widget in list view with no_open_chat set to true', async function (assert) { - assert.expect(3); - - const { widget: list } = await start({ - hasView: true, - View: ListView, - model: 'foo', - data: this.data, - arch: ``, - mockRPC(route, args) { - if (args.method === 'read') { - assert.step(`read ${args.model} ${args.args[0]}`); - } - return this._super(...arguments); - }, - }); - - mock.intercept(list, 'open_record', () => { - assert.step('open record'); - }); - - assert.strictEqual(list.$('.o_data_cell span').text(), 'MarioLuigiMarioYoshi'); - - // sanity check: later on, we'll check that clicking on the avatar doesn't open the record - await dom.click(list.$('.o_data_row:first span')); - - await dom.click(list.$('.o_data_cell:nth(0) .o_m2o_avatar > img')); - await dom.click(list.$('.o_data_cell:nth(1) .o_m2o_avatar > img')); - await dom.click(list.$('.o_data_cell:nth(2) .o_m2o_avatar > img')); - - - assert.verifySteps([ - 'open record', - ]); - - list.destroy(); - }); - - QUnit.test('many2one_avatar_user widget in kanban view', async function (assert) { - assert.expect(3); - - const { widget: kanban } = await start({ - hasView: true, - View: KanbanView, - model: 'foo', - data: this.data, - arch: ` - - - -
- -
-
-
-
`, - }); - - assert.strictEqual(kanban.$('.o_kanban_record').text().trim(), ''); - assert.containsN(kanban, '.o_m2o_avatar', 4); - dom.click(kanban.$('.o_m2o_avatar:nth(0) > img')); - dom.click(kanban.$('.o_m2o_avatar:nth(1) > img')); - dom.click(kanban.$('.o_m2o_avatar:nth(2) > img')); - dom.click(kanban.$('.o_m2o_avatar:nth(3) > img')); - - assert.verifySteps([], "no read res.user should be done since we don't want to open chat when the user clicks on avatar."); - - kanban.destroy(); - }); - - QUnit.test('many2many_avatar_user widget in form view', async function (assert) { - assert.expect(5); - - const { widget: form } = await start({ - hasView: true, - View: FormView, - model: 'foo', - data: this.data, - arch: `
`, - mockRPC(route, args) { - if (args.method === 'read') { - assert.step(`read ${args.model} ${args.args[0]}`); - } - return this._super(...arguments); - }, - res_id: 1, - }); - - assert.containsN(form, '.o_field_many2manytags.avatar.o_field_widget .badge', 2, - "should have 2 records"); - assert.strictEqual(form.$('.o_field_many2manytags.avatar.o_field_widget .badge:first img').data('src'), '/web/image/res.users/11/avatar_128', - "should have correct avatar image"); - - await dom.click(form.$('.o_field_many2manytags.avatar .badge:first .o_m2m_avatar')); - await dom.click(form.$('.o_field_many2manytags.avatar .badge:nth(1) .o_m2m_avatar')); - - assert.verifySteps([ - "read foo 1", - 'read res.users 11,23', - ]); - - form.destroy(); - }); - QUnit.test('many2one_avatar_user widget edited by the smart action "Assign to..."', async function (assert) { assert.expect(4); diff --git a/addons/project/controllers/portal.py b/addons/project/controllers/portal.py index 63cd3bd2a22..7737d1dc153 100644 --- a/addons/project/controllers/portal.py +++ b/addons/project/controllers/portal.py @@ -11,7 +11,7 @@ from odoo.http import request from odoo.addons.portal.controllers.portal import CustomerPortal, pager as portal_pager from odoo.tools import groupby as groupbyelem -from odoo.osv.expression import OR +from odoo.osv.expression import OR, AND from odoo.addons.web.controllers.main import HomeStaticTemplateHelpers @@ -30,75 +30,19 @@ class ProjectCustomerPortal(CustomerPortal): # My Project # ------------------------------------------------------------ def _project_get_page_view_values(self, project, access_token, page=1, date_begin=None, date_end=None, sortby=None, search=None, search_in='content', groupby=None, **kwargs): - # TODO: refactor this because most of this code is duplicated from portal_my_tasks method - values = self._prepare_portal_layout_values() - searchbar_sortings = self._task_get_searchbar_sortings() - - searchbar_inputs = self._task_get_searchbar_inputs() - searchbar_groupby = self._task_get_searchbar_groupby() - - # default sort by value - if not sortby: - sortby = 'date' - order = searchbar_sortings[sortby]['order'] - # default filter by value domain = [('project_id', '=', project.id)] - - # default group by value - if not groupby: - groupby = 'project' - - if date_begin and date_end: - domain += [('create_date', '>', date_begin), ('create_date', '<=', date_end)] - - # search - if search and search_in: - domain += self._task_get_search_domain(search_in, search) - - Task = request.env['project.task'] - if access_token: - Task = Task.sudo() - - # task count - task_count = Task.search_count(domain) # pager - url = "/my/project/%s" % project.id - pager = portal_pager( - url=url, - url_args={'date_begin': date_begin, 'date_end': date_end, 'sortby': sortby, 'groupby': groupby, 'search_in': search_in, 'search': search}, - total=task_count, - page=page, - step=self._items_per_page - ) - # content according to pager and archive selected - order = self._task_get_order(order, groupby) - - tasks = Task.search(domain, order=order, limit=self._items_per_page, offset=pager['offset']) - request.session['my_project_tasks_history'] = tasks.ids[:100] - - groupby_mapping = self._task_get_groupby_mapping() - group = groupby_mapping.get(groupby) - if group: - grouped_tasks = [Task.concat(*g) for k, g in groupbyelem(tasks, itemgetter(group))] - else: - grouped_tasks = [tasks] + url = "/my/projects/%s" % project.id + values = self._prepare_tasks_values(page, date_begin, date_end, sortby, search, search_in, groupby, url, domain, su=bool(access_token)) + pager = portal_pager(**values['pager']) values.update( - date=date_begin, - date_end=date_end, - grouped_tasks=grouped_tasks, + grouped_tasks=values['grouped_tasks'](pager['offset']), page_name='project', - default_url=url, pager=pager, - searchbar_sortings=searchbar_sortings, - searchbar_groupby=searchbar_groupby, - searchbar_inputs=searchbar_inputs, - search_in=search_in, - search=search, - sortby=sortby, - groupby=groupby, project=project, + task_url=f'projects/{project.id}/task', ) return self._get_page_view_values(project, access_token, values, 'my_projects_history', False, **kwargs) @@ -146,20 +90,35 @@ class ProjectCustomerPortal(CustomerPortal): }) return request.render("project.portal_my_projects", values) - @http.route(['/my/project/'], type='http', auth="public", website=True) - def portal_my_project(self, project_id=None, access_token=None, page=1, date_begin=None, date_end=None, sortby=None, search=None, search_in='content', groupby=None, **kw): + @http.route(['/my/project/', + '/my/project//page/', + '/my/project//task/', + '/my/project//project_sharing'], type='http', auth="public") + def portal_project_routes_outdated(self, **kwargs): + """ Redirect the outdated routes to the new routes. """ + return request.redirect(request.httprequest.path.replace('/my/project/', '/my/projects/')) + + @http.route(['/my/task', '/my/task/page/'], type='http', auth='public') + def portal_my_task_routes_outdated(self, **kwargs): + """ Redirect the outdated routes to the new routes. """ + return request.redirect(request.httprequest.path.replace('/my/task', '/my/tasks')) + + @http.route(['/my/projects/', '/my/projects//page/'], type='http', auth="public", website=True) + def portal_my_project(self, project_id=None, access_token=None, page=1, date_begin=None, date_end=None, sortby=None, search=None, search_in='content', groupby=None, task_id=None, **kw): try: project_sudo = self._document_check_access('project.project', project_id, access_token) except (AccessError, MissingError): return request.redirect('/my') if project_sudo.with_user(request.env.user)._check_project_sharing_access(): - return request.render("project.project_sharing_portal", {'project_id': project_id}) + values = {'project_id': project_id} + if task_id: + values['task_id'] = task_id + return request.render("project.project_sharing_portal", values) project_sudo = project_sudo if access_token else project_sudo.with_user(request.env.user) values = self._project_get_page_view_values(project_sudo, access_token, page, date_begin, date_end, sortby, search, search_in, groupby, **kw) - values['task_url'] = 'project/%s/task' % project_id return request.render("project.portal_my_project", values) - def _prepare_project_sharing_session_info(self, project): + def _prepare_project_sharing_session_info(self, project, task=None): session_info = request.env['ir.http'].session_info() user_context = request.session.get_context() if request.session.uid else {} mods = conf.server_wide_modules or [] @@ -188,20 +147,22 @@ class ProjectCustomerPortal(CustomerPortal): # FIXME: See if we prefer to give only the currency that the portal user just need to see the correct information in project sharing currencies=request.env['ir.http'].get_currencies(), ) + if task: + session_info['open_task_action'] = task.action_project_sharing_open_task() return session_info - @http.route("/my/project//project_sharing", type="http", auth="user", methods=['GET']) - def render_project_backend_view(self, project_id): + @http.route("/my/projects//project_sharing", type="http", auth="user", methods=['GET']) + def render_project_backend_view(self, project_id, task_id=None): project = request.env['project.project'].sudo().browse(project_id) if not project.exists() or not project.with_user(request.env.user)._check_project_sharing_access(): return request.not_found() - + task = task_id and request.env['project.task'].browse(int(task_id)) return request.render( 'project.project_sharing_embed', - {'session_info': self._prepare_project_sharing_session_info(project)}, + {'session_info': self._prepare_project_sharing_session_info(project, task)}, ) - @http.route('/my/project//task/', type='http', auth='public', website=True) + @http.route('/my/projects//task/', type='http', auth='public', website=True) def portal_my_project_task(self, project_id=None, task_id=None, access_token=None, **kw): try: project_sudo = self._document_check_access('project.project', project_id, access_token) @@ -216,6 +177,38 @@ class ProjectCustomerPortal(CustomerPortal): values['project'] = project_sudo return request.render("project.portal_my_task", values) + @http.route('/my/projects//task//subtasks', type='http', auth='user', methods=['GET'], website=True) + def portal_my_project_subtasks(self, project_id, task_id, page=1, date_begin=None, date_end=None, sortby=None, filterby=None, search=None, search_in='content', groupby=None, **kw): + try: + project_sudo = self._document_check_access('project.project', project_id) + task_sudo = request.env['project.task'].search([('project_id', '=', project_id), ('id', '=', task_id)]).sudo() + task_domain = [('id', 'child_of', task_id), ('id', '!=', task_id)] + searchbar_filters = self._get_my_tasks_searchbar_filters([('id', '=', task_sudo.project_id.id)], task_domain) + + if not filterby: + filterby = 'all' + domain = searchbar_filters.get(filterby, searchbar_filters.get('all'))['domain'] + + values = self._prepare_tasks_values(page, date_begin, date_end, sortby, search, search_in, groupby, url=f'/my/projects/{project_id}/task/{task_id}/subtasks', domain=AND([task_domain, domain])) + values['page_name'] = 'project_subtasks' + + # pager + pager_vals = values['pager'] + pager_vals['url_args'].update(filterby=filterby) + pager = portal_pager(**pager_vals) + + values.update({ + 'project': project_sudo, + 'task': task_sudo, + 'grouped_tasks': values['grouped_tasks'](pager['offset']), + 'pager': pager, + 'searchbar_filters': OrderedDict(sorted(searchbar_filters.items())), + 'filterby': filterby, + }) + return request.render("project.portal_my_tasks", values) + except (AccessError, MissingError): + return request.not_found() + # ------------------------------------------------------------ # My Task # ------------------------------------------------------------ @@ -320,48 +313,25 @@ class ProjectCustomerPortal(CustomerPortal): ]) return OR(search_domain) - @http.route(['/my/tasks', '/my/tasks/page/'], type='http', auth="user", website=True) - def portal_my_tasks(self, page=1, date_begin=None, date_end=None, sortby=None, filterby=None, search=None, search_in='content', groupby=None, **kw): + def _prepare_tasks_values(self, page, date_begin, date_end, sortby, search, search_in, groupby, url="/my/tasks", domain=None, su=False): values = self._prepare_portal_layout_values() - searchbar_sortings = self._task_get_searchbar_sortings() searchbar_sortings = dict(sorted(self._task_get_searchbar_sortings().items(), key=lambda item: item[1]["sequence"])) - - searchbar_filters = { - 'all': {'label': _('All'), 'domain': []}, - } - searchbar_inputs = self._task_get_searchbar_inputs() searchbar_groupby = self._task_get_searchbar_groupby() - # extends filterby criteria with project the customer has access to - projects = request.env['project.project'].search([]) - for project in projects: - searchbar_filters.update({ - str(project.id): {'label': project.name, 'domain': [('project_id', '=', project.id)]} - }) + Task = request.env['project.task'] + if su: + Task = Task.sudo() - # extends filterby criteria with project (criteria name is the project id) - # Note: portal users can't view projects they don't follow - project_groups = request.env['project.task'].read_group([('project_id', 'not in', projects.ids)], - ['project_id'], ['project_id']) - for group in project_groups: - proj_id = group['project_id'][0] if group['project_id'] else False - proj_name = group['project_id'][1] if group['project_id'] else _('Others') - searchbar_filters.update({ - str(proj_id): {'label': proj_name, 'domain': [('project_id', '=', proj_id)]} - }) + if not domain: + domain = [] # default sort by value if not sortby: sortby = 'date' order = searchbar_sortings[sortby]['order'] - # default filter by value - if not filterby: - filterby = 'all' - domain = searchbar_filters.get(filterby, searchbar_filters.get('all'))['domain'] - # default group by value if not groupby: groupby = 'project' @@ -373,44 +343,42 @@ class ProjectCustomerPortal(CustomerPortal): if search and search_in: domain += self._task_get_search_domain(search_in, search) - # task count - task_count = request.env['project.task'].search_count(domain) - # pager - pager = portal_pager( - url="/my/tasks", - url_args={'date_begin': date_begin, 'date_end': date_end, 'sortby': sortby, 'filterby': filterby, 'groupby': groupby, 'search_in': search_in, 'search': search}, - total=task_count, - page=page, - step=self._items_per_page - ) # content according to pager and archive selected order = self._task_get_order(order, groupby) - tasks = request.env['project.task'].search(domain, order=order, limit=self._items_per_page, offset=pager['offset']) - request.session['my_tasks_history'] = tasks.ids[:100] + def get_grouped_tasks(pager_offset): + tasks = Task.search(domain, order=order, limit=self._items_per_page, offset=pager_offset) + request.session['my_project_tasks_history' if url.startswith('/my/projects') else 'my_tasks_history'] = tasks.ids[:100] - groupby_mapping = self._task_get_groupby_mapping() - group = groupby_mapping.get(groupby) - if group: - grouped_tasks = [request.env['project.task'].concat(*g) for k, g in groupbyelem(tasks, itemgetter(group))] - else: - grouped_tasks = [tasks] - - task_states = dict(request.env['project.task']._fields['kanban_state']._description_selection(request.env)) - if sortby == 'status': - if groupby == 'none' and grouped_tasks: - grouped_tasks[0] = grouped_tasks[0].sorted(lambda tasks: task_states.get(tasks.kanban_state)) + groupby_mapping = self._task_get_groupby_mapping() + group = groupby_mapping.get(groupby) + if group: + grouped_tasks = [Task.concat(*g) for k, g in groupbyelem(tasks, itemgetter(group))] else: - grouped_tasks.sort(key=lambda tasks: task_states.get(tasks[0].kanban_state)) + grouped_tasks = [tasks] + + task_states = dict(Task._fields['kanban_state']._description_selection(request.env)) + if sortby == 'status': + if groupby == 'none' and grouped_tasks: + grouped_tasks[0] = grouped_tasks[0].sorted(lambda tasks: task_states.get(tasks.kanban_state)) + else: + grouped_tasks.sort(key=lambda tasks: task_states.get(tasks[0].kanban_state)) + return grouped_tasks values.update({ 'date': date_begin, 'date_end': date_end, - 'grouped_tasks': grouped_tasks, + 'grouped_tasks': get_grouped_tasks, 'page_name': 'task', - 'default_url': '/my/tasks', - 'task_url': 'task', - 'pager': pager, + 'default_url': url, + 'task_url': 'tasks', + 'pager': { + "url": url, + "url_args": {'date_begin': date_begin, 'date_end': date_end, 'sortby': sortby, 'groupby': groupby, 'search_in': search_in, 'search': search}, + "total": Task.search_count(domain), + "page": page, + "step": self._items_per_page + }, 'searchbar_sortings': searchbar_sortings, 'searchbar_groupby': searchbar_groupby, 'searchbar_inputs': searchbar_inputs, @@ -418,6 +386,51 @@ class ProjectCustomerPortal(CustomerPortal): 'search': search, 'sortby': sortby, 'groupby': groupby, + }) + return values + + def _get_my_tasks_searchbar_filters(self, project_domain=None, task_domain=None): + searchbar_filters = { + 'all': {'label': _('All'), 'domain': []}, + } + + # extends filterby criteria with project the customer has access to + projects = request.env['project.project'].search(project_domain or []) + for project in projects: + searchbar_filters.update({ + str(project.id): {'label': project.name, 'domain': [('project_id', '=', project.id)]} + }) + + # extends filterby criteria with project (criteria name is the project id) + # Note: portal users can't view projects they don't follow + project_groups = request.env['project.task'].read_group(AND([[('project_id', 'not in', projects.ids)], task_domain or []]), + ['project_id'], ['project_id']) + for group in project_groups: + proj_id = group['project_id'][0] if group['project_id'] else False + proj_name = group['project_id'][1] if group['project_id'] else _('Others') + searchbar_filters.update({ + str(proj_id): {'label': proj_name, 'domain': [('project_id', '=', proj_id)]} + }) + return searchbar_filters + + @http.route(['/my/tasks', '/my/tasks/page/'], type='http', auth="user", website=True) + def portal_my_tasks(self, page=1, date_begin=None, date_end=None, sortby=None, filterby=None, search=None, search_in='content', groupby=None, **kw): + searchbar_filters = self._get_my_tasks_searchbar_filters() + + if not filterby: + filterby = 'all' + domain = searchbar_filters.get(filterby, searchbar_filters.get('all'))['domain'] + + values = self._prepare_tasks_values(page, date_begin, date_end, sortby, search, search_in, groupby, domain=domain) + + # pager + pager_vals = values['pager'] + pager_vals['url_args'].update(filterby=filterby) + pager = portal_pager(**pager_vals) + + values.update({ + 'grouped_tasks': values['grouped_tasks'](pager['offset']), + 'pager': pager, 'searchbar_filters': OrderedDict(sorted(searchbar_filters.items())), 'filterby': filterby, }) @@ -428,8 +441,8 @@ class ProjectCustomerPortal(CustomerPortal): # The route should not be called if at least hr_timesheet is not installed raise MissingError(_('There is nothing to report.')) - @http.route(['/my/task/'], type='http', auth="public", website=True) - def portal_my_task(self, task_id, report_type=None, access_token=None, **kw): + @http.route(['/my/tasks/'], type='http', auth="public", website=True) + def portal_my_task(self, task_id, report_type=None, access_token=None, project_sharing=False, **kw): try: task_sudo = self._document_check_access('project.task', task_id, access_token) except (AccessError, MissingError): @@ -441,5 +454,9 @@ class ProjectCustomerPortal(CustomerPortal): # ensure attachment are accessible with access token inside template for attachment in task_sudo.attachment_ids: attachment.generate_access_token() + if project_sharing is True: + # Then the user arrives to the stat button shown in form view of project.task and the portal user can see only 1 task + # so the history should be reset. + request.session['my_tasks_history'] = task_sudo.ids values = self._task_get_page_view_values(task_sudo, access_token, **kw) return request.render("project.portal_my_task", values) diff --git a/addons/project/models/project.py b/addons/project/models/project.py index 00faf9fed40..fd26fbea16b 100644 --- a/addons/project/models/project.py +++ b/addons/project/models/project.py @@ -44,6 +44,7 @@ PROJECT_TASK_READABLE_FIELDS = { 'legend_blocked', 'legend_done', 'user_ids', + 'display_parent_task_button', } PROJECT_TASK_WRITABLE_FIELDS = { @@ -363,7 +364,7 @@ class Project(models.Model): def _compute_access_url(self): super(Project, self)._compute_access_url() for project in self: - project.access_url = '/my/project/%s' % project.id + project.access_url = f'/my/projects/{project.id}' def _compute_access_warning(self): super(Project, self)._compute_access_warning() @@ -1038,6 +1039,9 @@ class Task(models.Model): domain="[('allow_task_dependencies', '=', True), ('id', '!=', id)]") dependent_tasks_count = fields.Integer(string="Dependent Tasks", compute='_compute_dependent_tasks_count') + # Project sharing fields + display_parent_task_button = fields.Boolean(compute='_compute_display_parent_task_button', compute_sudo=True) + # recurrence fields allow_recurring_tasks = fields.Boolean(related='project_id.allow_recurring_tasks') recurring_task = fields.Boolean(string="Recurrent") @@ -1393,7 +1397,7 @@ class Task(models.Model): def _compute_access_url(self): super(Task, self)._compute_access_url() for task in self: - task.access_url = '/my/task/%s' % task.id + task.access_url = f'/my/tasks/{task.id}' def _compute_access_warning(self): super(Task, self)._compute_access_warning() @@ -1470,6 +1474,11 @@ class Task(models.Model): """ return [('id', 'inselect', (query, [f'%{value}%']))] + def _compute_display_parent_task_button(self): + accessible_parent_tasks = self.parent_id.with_user(self.env.user)._filter_access_rules('read') + for task in self: + task.display_parent_task_button = task.parent_id in accessible_parent_tasks + @api.returns('self', lambda value: value.id) def copy(self, default=None): if default is None: @@ -2113,6 +2122,26 @@ class Task(models.Model): 'context': self._context } + def action_project_sharing_view_parent_task(self): + if self.parent_id.project_id != self.project_id and self.user_has_groups('base.group_portal'): + project = self.parent_id.project_id._filter_access_rules_python('read') + if project: + url = f"/my/projects/{self.parent_id.project_id.id}/task/{self.parent_id.id}" + if project._check_project_sharing_access(): + url = f"/my/projects/{self.parent_id.project_id.id}?task_id={self.parent_id.id}" + return { + "name": "Portal Parent Task", + "type": "ir.actions.act_url", + "url": url, + } + elif self.display_parent_task_button: + return self.parent_id.get_portal_url() + # The portal user has no access to the parent task, so normally the button should be invisible. + return {} + action = self.action_open_parent_task() + action['views'] = [(self.env.ref('project.project_sharing_project_task_view_form').id, 'form')] + return action + # ------------ # Actions # ------------ @@ -2126,6 +2155,27 @@ class Task(models.Model): 'context': self._context } + def action_project_sharing_open_task(self): + action = self.action_open_task() + action['views'] = [[self.env.ref('project.project_sharing_project_task_view_form').id, 'form']] + return action + + def action_project_sharing_open_subtasks(self): + self.ensure_one() + subtasks = self.env['project.task'].search([('id', 'child_of', self.id), ('id', '!=', self.id)]) + if subtasks.project_id == self.project_id: + action = self.env['ir.actions.act_window']._for_xml_id('project.project_sharing_project_task_action_sub_task') + if len(subtasks) == 1: + action['view_mode'] = 'form' + action['views'] = [(view_id, view_type) for view_id, view_type in action['views'] if view_type == 'form'] + action['res_id'] = subtasks.id + return action + return { + 'name': 'Portal Sub-tasks', + 'type': 'ir.actions.act_url', + 'url': f'/my/projects/{self.project_id.id}/task/{self.id}/subtasks' if len(subtasks) > 1 else subtasks.get_portal_url(query_string='project_sharing=1'), + } + def action_dependent_tasks(self): self.ensure_one() action = { diff --git a/addons/project/static/src/project_sharing/project_sharing.js b/addons/project/static/src/project_sharing/project_sharing.js index 0f9a53db202..98d4118cffd 100644 --- a/addons/project/static/src/project_sharing/project_sharing.js +++ b/addons/project/static/src/project_sharing/project_sharing.js @@ -43,7 +43,7 @@ export class ProjectSharingWebClient extends Component { } async _showView() { - const { action_name, project_id } = session; + const { action_name, project_id, open_task_action } = session; await this.actionService.doAction( action_name, { @@ -53,6 +53,9 @@ export class ProjectSharingWebClient extends Component { } } ); + if (open_task_action) { + await this.actionService.doAction(open_task_action); + } } } diff --git a/addons/project/static/src/project_sharing/project_sharing.xml b/addons/project/static/src/project_sharing/project_sharing.xml index 9f021f9530d..3ebf4fe49ef 100644 --- a/addons/project/static/src/project_sharing/project_sharing.xml +++ b/addons/project/static/src/project_sharing/project_sharing.xml @@ -2,13 +2,10 @@ - - - - -
- - + + + + diff --git a/addons/project/static/tests/tours/project_sharing_tour.js b/addons/project/static/tests/tours/project_sharing_tour.js index b744f44f943..671f5c6a0e9 100644 --- a/addons/project/static/tests/tours/project_sharing_tour.js +++ b/addons/project/static/tests/tours/project_sharing_tour.js @@ -2,10 +2,7 @@ import tour from 'web_tour.tour'; -tour.register('project_sharing_tour', { - test: true, - url: '/web', -}, [...tour.stepUtils.goToAppSteps("project.menu_main_pm", 'Go to the Project App.'), { +const projectSharingSteps = [...tour.stepUtils.goToAppSteps("project.menu_main_pm", 'Go to the Project App.'), { // an invisible element cannot be used as a trigger so this small hack is mandatory for the next step trigger: '.o_kanban_record:contains("Project Sharing")', run: function () { @@ -39,6 +36,7 @@ tour.register('project_sharing_tour', { window.location.href = window.location.origin + '/my/projects'; }, }, { + id: 'project_sharing_feature', trigger: 'table > tbody > tr a:has(span:contains(Project Sharing))', content: 'Select "Project Sharing" project to go to project sharing feature for this project.', }, { @@ -87,66 +85,37 @@ tour.register('project_sharing_tour', { }, { trigger: '.o_favorite_menu .o_add_favorite > button', content: 'click to "save current search" button in favorite menu', +}, { + trigger: '.o_filter_menu > button', + content: 'click on filter menu in the search view', +}, { + trigger: '.o_filter_menu > .dropdown-menu > .dropdown-item:first-child', + content: 'click on the first item in the filter menu', +}, { + trigger: '.o_group_by_menu > button', + content: 'click on group by menu in the search view', +}, { + trigger: '.o_group_by_menu > .dropdown-menu > .dropdown-item:first-child', + content: 'click on the first item in the group by menu', +}, { + trigger: '.o_favorite_menu > button', + content: 'click on the favorite menu in the search view', +}, { + trigger: '.o_favorite_menu .o_add_favorite > button', + content: 'click to "save current search" button in favorite menu', }, { trigger: 'button.o_switch_view.o_list', content: 'Go to the list view', - run: 'click', -}]); +}]; +tour.register('project_sharing_tour', { + test: true, + url: '/web', +}, projectSharingSteps); + +// The begining of the project sharing feature +const projectSharingStepIndex = projectSharingSteps.findIndex(s => s.id && s.id === 'project_sharing_feature'); tour.register('portal_project_sharing_tour', { test: true, url: '/my/projects', -}, [{ - trigger: 'table > tbody > tr a:has(span:contains(Project Sharing))', - content: 'Select "Project Sharing" project to go to project sharing feature for this project.', -}, { - trigger: '.o_project_sharing', - content: 'Wait the project sharing feature be loaded', - run: function () {}, -}, { - trigger: 'button.o-kanban-button-new', - content: 'Click "Create" button', - run: 'click', -}, { - trigger: '.o_kanban_quick_create .o_field_widget[name="name"]', - content: 'Create Task', - run: 'text Test Create Task', -}, { - trigger: '.o_kanban_quick_create .o_kanban_edit', - content: 'Go to the form view of this new task', -}, { - trigger: 'div.o_statusbar_status[name="stage_id"] button[aria-checked="false"]:contains(Done)', - content: 'Change the stage of the task.', -}, { - trigger: '.o_portal_chatter_composer_input .o_portal_chatter_composer_body textarea[name="message"]', - content: 'Write a message in the chatter of the task', - run: 'text I create a new task for testing purpose.', -}, { - trigger: '.o_portal_chatter_composer_input .o_portal_chatter_composer_body button[data-action="/mail/chatter_post"]', - content: 'Send the message', -}, { - trigger: 'ol.breadcrumb > li.o_back_button > a:contains(Project Sharing)', - content: 'Go back to the kanban view', -}, { - trigger: '.o_filter_menu > button', - content: 'click on filter menu in the search view', -}, { - trigger: '.o_filter_menu > .dropdown-menu > .dropdown-item:first-child', - content: 'click on the first item in the filter menu', -}, { - trigger: '.o_group_by_menu > button', - content: 'click on group by menu in the search view', -}, { - trigger: '.o_group_by_menu > .dropdown-menu > .dropdown-item:first-child', - content: 'click on the first item in the group by menu', -}, { - trigger: '.o_favorite_menu > button', - content: 'click on the favorite menu in the search view', -}, { - trigger: '.o_favorite_menu .o_add_favorite > button', - content: 'click to "save current search" button in favorite menu', -}, { - trigger: 'button.o_switch_view.o_list', - content: 'Go to the list view', - run: 'click', -}]); +}, projectSharingSteps.slice(projectSharingStepIndex, projectSharingSteps.length)); diff --git a/addons/project/views/project_portal_templates.xml b/addons/project/views/project_portal_templates.xml index 17ea2b8d22b..2e055e463d1 100644 --- a/addons/project/views/project_portal_templates.xml +++ b/addons/project/views/project_portal_templates.xml @@ -6,8 +6,8 @@ Projects Projects -
  • @@ -16,9 +16,19 @@ Tasks Tasks
  • - + +
  • + Sub-tasks + Sub-tasks +
  • + @@ -61,7 +71,7 @@ - + @@ -262,7 +272,7 @@