[MERGE] project sharing follow up

Follow up of tasks 2379518 and 2633229 (#73341 and #77156).

Purpose
=======

The main goal is to allow the portal user to click on some stat button shown in the task form view in the project sharing feature. Those stat button will redirect to a portal view if the portal user has access to the document(s).

Then, some refactoring is made to easily use the existing code for one route to another one and review the code for the project sharing tour.

Finally, the `/my/project` and `/my/task` routes have been renamed to follow the same *naming* in the another routes. That is, those routes will be named `/my/projects` and `/my/tasks`.

## Details of the implementation

- Allow the portal user to click on the sale order stat button in task form view. This stat button will be clickable if the user has access to the sale order of the current task shown in the form view, otherwise the stat button will be invisible. If a portal user clicks on this button, then he will be redirect to `/my/orders/<id of the sale order>`.
- Allow to show the parent task from another project than the one shown in project sharing. If the portal user has access to the other project than there is no reason to not allow him to see the parent task.
- Rename the `/my/project` route into `/my/projects`.
- Rename the `/my/task` route into `/my/tasks`.
- Create hook methods to avoid duplicating code when we want to use this code to another route. It will be the case for a new route used in the project sharing feature.
- Create hook method in controller of account module to easily reuse the existing code for another route for project sharing feature.
- Refactor tour js for project sharing feature to remove the duplicating code.
- Revert the disable click on avatar. This changes was initially done for project sharing feature. However, this changes is made `web` module instead of the `project`. Since this changes is no longer used in project sharing feature and no used in backend code, the changes is reverted.
- Move the `invoice_count` field from `sale_project` module to `industry_fsm_sale` one because it seems this field is only used in that module and not anywhere before installing that module.

task-2648955

--
I confirm I have signed the CLA and read the PR guidelines at www.odoo.com/submit-pr

closes odoo/odoo#82379

Signed-off-by: Laurent Stukkens (ltu) <ltu@odoo.com>
This commit is contained in:
Odoo's Mergebot
2022-02-14 15:50:59 +01:00
committed by GitHub
18 changed files with 383 additions and 373 deletions
+34 -20
View File
@@ -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/<int: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/<int:invoice_id>'], type='http', auth="public", website=True)
def portal_my_invoice_detail(self, invoice_id, access_token=None, report_type=None, download=False, **kw):
+2 -2
View File
@@ -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
@@ -161,15 +161,4 @@
</field>
</record>
<record id="project_sharing_inherit_project_task_view_search" model="ir.ui.view">
<field name="name">project.sharing.inherit.project.task.view.search</field>
<field name="model">project.task</field>
<field name="inherit_id" ref="project.project_sharing_project_task_view_search"/>
<field name="arch" type="xml">
<xpath expr="//filter[@name='late']" position='after'>
<filter string="Tasks in Overtime" name="overtime" domain="[('overtime', '&gt;', 0)]"/>
</xpath>
</field>
</record>
</odoo>
+3 -7
View File
@@ -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({
@@ -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: `<tree><field name="user_id" widget="many2one_avatar_user" options="{'no_open_chat': 1}"/></tree>`,
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: `
<kanban>
<templates>
<t t-name="kanban-box">
<div>
<field name="user_id" widget="many2one_avatar_user" options="{'no_open_chat': 1}"/>
</div>
</t>
</templates>
</kanban>`,
});
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: `<form><field name="user_ids" widget="many2many_avatar_user" options="{'no_open_chat': 1}"/></form>`,
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);
+148 -131
View File
@@ -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/<int:project_id>'], 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/<int:project_id>',
'/my/project/<int:project_id>/page/<int:page>',
'/my/project/<int:project_id>/task/<int:task_id>',
'/my/project/<int:project_id>/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/<int: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/<int:project_id>', '/my/projects/<int:project_id>/page/<int: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/<int:project_id>/project_sharing", type="http", auth="user", methods=['GET'])
def render_project_backend_view(self, project_id):
@http.route("/my/projects/<int:project_id>/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/<int:project_id>/task/<int:task_id>', type='http', auth='public', website=True)
@http.route('/my/projects/<int:project_id>/task/<int:task_id>', 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/<int:project_id>/task/<int:task_id>/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/<int: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/<int: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/<int:task_id>'], type='http', auth="public", website=True)
def portal_my_task(self, task_id, report_type=None, access_token=None, **kw):
@http.route(['/my/tasks/<int:task_id>'], 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)
+52 -2
View File
@@ -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 = {
@@ -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);
}
}
}
@@ -2,13 +2,10 @@
<templates xml:space="preserve">
<t t-name="project.ProjectSharingWebClient" owl="1">
<body class="o_project_sharing">
<NotUpdatable>
<ActionContainer />
</NotUpdatable>
<div class="o_dialog_container"/>
<MainComponentsContainer/>
</body>
<NotUpdatable>
<ActionContainer />
</NotUpdatable>
<MainComponentsContainer/>
</t>
</templates>
@@ -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));
@@ -6,8 +6,8 @@
<a t-if="project" t-attf-href="/my/projects?{{ keep_query() }}">Projects</a>
<t t-else="">Projects</t>
</li>
<li t-if="page_name == 'project_task' and project" class="breadcrumb-item active">
<a t-if="project" t-attf-href="/my/project/{{ project.id }}?{{ keep_query() }}"><t t-esc="project.name"/></a>
<li t-if="page_name in ['project_task', 'project_subtasks'] and project" class="breadcrumb-item active">
<a t-if="project" t-attf-href="/my/projects/{{ project.id }}?{{ keep_query() }}"><t t-esc="project.name"/></a>
</li>
<li t-elif="project" t-attf-class="breadcrumb-item #{'active ' if not project else ''} text-truncate col-8 col-lg-10">
<t t-esc="project.name"/>
@@ -16,9 +16,19 @@
<a t-if="task" t-attf-href="/my/tasks?{{ keep_query() }}">Tasks</a>
<t t-else="">Tasks</t>
</li>
<li t-if="task" class="breadcrumb-item active text-truncate">
<li t-if="page_name == 'project_subtasks' and task and project" class="breadcrumb-item active">
<a t-attf-href="/my/projects/{{ project.id }}/task/{{ task.id }}?{{ keep_query() }}"><t t-esc="task.name"/></a>
</li>
<li t-elif="task" class="breadcrumb-item active text-truncate">
<span t-field="task.name"/>
</li>
<li t-if="page_name == 'project_subtasks' or (task and subtask and project)" t-attf-class="breadcrumb-item text-truncate #{'active ' if not subtask else ''}">
<a t-if="subtask" t-attf-href="/my/tasks/{{ task.id }}/subtasks?{{ keep_query() }}">Sub-tasks</a>
<t t-else="">Sub-tasks</t>
</li>
<li t-if="subtask" class="breadcrumb-item active text-truncate">
<span t-field="subtask.name"/>
</li>
</xpath>
</template>
@@ -61,7 +71,7 @@
<tbody>
<tr t-foreach="projects" t-as="project">
<td>
<a t-attf-href="/my/project/#{project.id}?{{ keep_query() }}"><span t-field="project.name"/></a>
<a t-attf-href="/my/projects/#{project.id}?{{ keep_query() }}"><span t-field="project.name"/></a>
</td>
<td class="text-right">
<t t-out="project.task_count_with_subtasks" />
@@ -262,7 +272,7 @@
</div>
<div class="row mb-4">
<div class="col-12 col-md-6">
<div t-if="project_accessible"><strong>Project:</strong> <a t-attf-href="/my/project/#{task.project_id.id}" t-field="task.project_id"/></div>
<div t-if="project_accessible"><strong>Project:</strong> <a t-attf-href="/my/projects/#{task.project_id.id}" t-field="task.project_id"/></div>
<div t-else=""><strong>Project:</strong> <a t-field="task.project_id"/></div>
<div t-if="task.date_deadline"><strong>Deadline:</strong> <span t-field="task.date_deadline" t-options='{"widget": "date"}'/></div>
<div name="portal_my_task_planned_hours">
@@ -9,7 +9,7 @@
</template>
<template id="project_sharing" name="Project Sharing View">
<iframe width="100%" height="100%" frameborder="0" t-attf-src="/my/project/{{ project_id }}/project_sharing"/>
<iframe width="100%" height="100%" frameborder="0" t-attf-src="/my/projects/{{ project_id }}/project_sharing{{ '?task_id=' + task_id if task_id else '' }}"/>
</template>
<template id="project_sharing_embed" name="Project Sharing View Embed">
@@ -27,7 +27,7 @@
<t t-call="web.conditional_assets_tests"/>
</t>
<t t-set="head" t-value="head_project_sharing + (head or '')"/>
<t t-set="body_classname" t-value="'o_web_client'"/>
<t t-set="body_classname" t-value="'o_web_client o_project_sharing'"/>
</t>
</template>
+47 -2
View File
@@ -103,7 +103,7 @@
</span>
<field name="kanban_state" widget="state_selection"/>
<!-- TODO: [XBO] remove me in master -->
<field name="user_ids" string="Assignees" widget="many2many_avatar_user" options="{'no_open_chat': True}" invisible="1"/>
<field name="user_ids" string="Assignees" widget="many2many_avatar_user" invisible="1"/>
</div>
</div>
</div>
@@ -159,7 +159,17 @@
</header>
<sheet string="Task">
<div class="oe_button_box" name="button_box">
<button name="action_open_parent_task" type="object" special="cancel" class="oe_stat_button o_debounce_disabled" icon="fa-tasks" string="Parent Task" attrs="{'invisible': [('parent_id', '=', False)]}"/>
<field name="display_parent_task_button" invisible="1"/>
<button name="action_project_sharing_view_parent_task" type="object" class="oe_stat_button" icon="fa-tasks" string="Parent Task" attrs="{'invisible': [('display_parent_task_button', '=', False)]}"/>
<button name="action_project_sharing_open_subtasks" type="object" class="oe_stat_button" icon="fa-tasks"
attrs="{'invisible' : ['|', '|', ('allow_subtasks', '=', False), ('id', '=', False), ('subtask_count', '=', 0)]}" context="{'default_user_ids': [(6, 0, [uid])]}">
<div class="o_field_widget o_stat_info">
<span class="o_stat_value">
<field name="subtask_count" widget="statinfo" nolabel="1"/>
</span>
<span class="o_stat_text">Sub-tasks</span>
</div>
</button>
</div>
<widget name="web_ribbon" title="Archived" bg_color="bg-danger" attrs="{'invisible': [('active', '=', True)]}"/>
<div class="oe_title pr-0">
@@ -304,4 +314,39 @@
<field name="view_id" ref="project.project_sharing_project_task_view_form"/>
</record>
<record id="project_sharing_project_task_action_sub_task" model="ir.actions.act_window">
<field name="name">Sub-tasks</field>
<field name="res_model">project.task</field>
<field name="view_mode">tree,kanban,form</field>
<field name="search_view_id" ref="project.project_sharing_project_task_view_search"/>
<field name="domain">[('id', 'child_of', active_id), ('id', '!=', active_id)]</field>
<field name="context">{'default_parent_id': active_id}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No tasks found. Let's create one!
</p><p>
To get things done, use activities and status on tasks.<br/>
Chat in real time or by email to collaborate efficiently.
</p>
</field>
</record>
<record id="project_sharing_subtasks_tree_action_view" model="ir.actions.act_window.view">
<field name="view_mode">tree</field>
<field name="act_window_id" ref="project.project_sharing_project_task_action_sub_task"/>
<field name="view_id" ref="project.project_sharing_project_task_view_tree"/>
</record>
<record id="project_sharing_subtasks_kanban_action_view" model="ir.actions.act_window.view">
<field name="view_mode">kanban</field>
<field name="act_window_id" ref="project.project_sharing_project_task_action_sub_task"/>
<field name="view_id" ref="project.project_sharing_project_task_view_kanban"/>
</record>
<record id="project_sharing_subtasks_form_action_view" model="ir.actions.act_window.view">
<field name="view_mode">form</field>
<field name="act_window_id" ref="project.project_sharing_project_task_action_sub_task"/>
<field name="view_id" ref="project.project_sharing_project_task_view_form"/>
</record>
</odoo>
+9 -11
View File
@@ -39,6 +39,13 @@ class CustomerPortal(portal.CustomerPortal):
# Quotations and Sales Orders
#
def _get_sale_searchbar_sortings(self):
return {
'date': {'label': _('Order Date'), 'order': 'date_order desc'},
'name': {'label': _('Reference'), 'order': 'name'},
'stage': {'label': _('Stage'), 'order': 'state'},
}
@http.route(['/my/quotes', '/my/quotes/page/<int:page>'], type='http', auth="user", website=True)
def portal_my_quotes(self, page=1, date_begin=None, date_end=None, sortby=None, **kw):
values = self._prepare_portal_layout_values()
@@ -50,12 +57,7 @@ class CustomerPortal(portal.CustomerPortal):
('state', 'in', ['sent', 'cancel'])
]
searchbar_sortings = {
'date': {'label': _('Order Date'), 'order': 'date_order desc'},
'name': {'label': _('Reference'), 'order': 'name'},
'stage': {'label': _('Stage'), 'order': 'state'},
}
searchbar_sortings = self._get_sale_searchbar_sortings()
# default sortby order
if not sortby:
sortby = 'date'
@@ -100,11 +102,7 @@ class CustomerPortal(portal.CustomerPortal):
('state', 'in', ['sale', 'done'])
]
searchbar_sortings = {
'date': {'label': _('Order Date'), 'order': 'date_order desc'},
'name': {'label': _('Reference'), 'order': 'name'},
'stage': {'label': _('Stage'), 'order': 'state'},
}
searchbar_sortings = self._get_sale_searchbar_sortings()
# default sortby order
if not sortby:
sortby = 'date'
+29 -4
View File
@@ -4,7 +4,7 @@
from ast import literal_eval
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError, UserError
from odoo.exceptions import ValidationError, AccessError
class Project(models.Model):
@@ -146,12 +146,14 @@ class ProjectTask(models.Model):
domain="[('company_id', '=', company_id), ('is_service', '=', True), ('order_partner_id', 'child_of', commercial_partner_id), ('is_expense', '=', False), ('state', 'in', ['sale', 'done'])]",
help="Sales Order Item to which the time spent on this task will be added, in order to be invoiced to your customer.")
project_sale_order_id = fields.Many2one('sale.order', string="Project's sale order", related='project_id.sale_order_id')
invoice_count = fields.Integer("Number of invoices", related='sale_order_id.invoice_count')
task_to_invoice = fields.Boolean("To invoice", compute='_compute_task_to_invoice', search='_search_task_to_invoice', groups='sales_team.group_sale_salesman_all_leads')
# Project sharing fields
display_sale_order_button = fields.Boolean(string='Display Sales Order', compute='_compute_display_sale_order_button')
@property
def SELF_READABLE_FIELDS(self):
return super().SELF_READABLE_FIELDS | {'sale_order_id', 'sale_line_id'}
return super().SELF_READABLE_FIELDS | {'sale_order_id', 'sale_line_id', 'display_sale_order_button'}
@api.depends('sale_line_id', 'project_id', 'commercial_partner_id')
def _compute_sale_order_id(self):
@@ -177,6 +179,18 @@ class ProjectTask(models.Model):
if task.sale_line_id.order_partner_id.commercial_partner_id != task.partner_id.commercial_partner_id:
task.sale_line_id = False
@api.depends('sale_order_id')
def _compute_display_sale_order_button(self):
if not self.sale_order_id:
self.display_sale_order_button = False
return
try:
sale_orders = self.env['sale.order'].search([('id', 'in', self.sale_order_id.ids)])
for task in self:
task.display_sale_order_button = task.sale_order_id in sale_orders
except AccessError:
self.display_sale_order_button = False
@api.constrains('sale_line_id')
def _check_sale_line_type(self):
for task in self.sudo():
@@ -196,7 +210,6 @@ class ProjectTask(models.Model):
return self.sale_order_id.ids
def action_view_so(self):
self.ensure_one()
so_ids = self._get_action_view_so_ids()
action_window = {
"type": "ir.actions.act_window",
@@ -212,6 +225,18 @@ class ProjectTask(models.Model):
return action_window
def action_project_sharing_view_so(self):
self.ensure_one()
if self.user_has_groups('base.group_portal'):
if not self.display_sale_order_button:
return {}
return {
"name": "Portal Sale Order",
"type": "ir.actions.act_url",
"url": self.sale_order_id.access_url,
}
return self.action_view_so()
def rating_get_partner_id(self):
partner = self.partner_id or self.sale_line_id.order_id.partner_id
if partner:
@@ -7,10 +7,10 @@
<field name="inherit_id" ref="project.project_sharing_project_task_view_form"/>
<field name="arch" type="xml">
<div name="button_box" position="inside">
<field name="sale_order_id" invisible="1"/>
<field name="display_sale_order_button" invisible="1"/>
<button class="d-none d-md-inline oe_stat_button"
type="object" name="action_view_so" icon="fa-dollar"
invisible="1"
type="object" name="action_project_sharing_view_so" icon="fa-dollar"
attrs="{'invisible': [('display_sale_order_button', '=', False)]}"
string="Sales Order"/>
</div>
<xpath expr="//field[@name='partner_id']" position="attributes">
@@ -19,6 +19,7 @@
<xpath expr="//field[@name='remaining_hours']" position="after">
<field name="allow_billable" invisible="1" />
<field name="remaining_hours_available" invisible="1"/>
<field name="sale_order_id" invisible="1"/>
<span id="remaining_hours_so_label" attrs="{'invisible': ['|', '|', '|', '|', ('allow_billable', '=', False), ('sale_order_id', '=', False), ('partner_id', '=', False), ('sale_line_id', '=', False), ('remaining_hours_available', '=', False)]}">
<label class="font-weight-bold" for="remaining_hours_so" string="Remaining Hours on SO"
attrs="{'invisible': ['|', ('encode_uom_in_days', '=', True), ('remaining_hours_so', '&lt;', 0)]}"/>
+1 -1
View File
@@ -322,7 +322,7 @@ class IrHttp(models.AbstractModel):
record = record_sudo
elif self.env.user.has_group('base.group_portal'):
# Check the read access on the record linked to the attachment
# eg: Allow to download an attachment on a task from /my/task/task_id
# eg: Allow to download an attachment on a task from /my/tasks/task_id
record.check('read')
record = record_sudo