[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:
@@ -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):
|
||||
|
||||
@@ -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', '>', 0)]"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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', '<', 0)]}"/>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user