[MERGE] {sale_}project,{hr,sale}_timesheet,mail: add Project Sharing feature

Purpose:
=======

This commit adds the project sharing feature. This feature consists to
share the backend views about project.task to portal users.

About the access rights:
- The project manager can share a project with project sharing feature.
  To do this, he need to give access to portal users that we want their access in project sharing views.
  He can choose between 2 differents accesses:
    1. **Readonly**: The use has access to the classic portal views (on which he can post messages, and have a readonly access rights to the related fields)
    2. **Edit**: the portal with this access right will can edit some fields of `project.task` model.

The difference between the classic backend views and the project sharing
views is in the project sharing views, we list the fields that the user
can read/write, so the portal users can see only the fields in
our list.
Moreover, the actions and the contextual menu (actions dropdown) are not
available in the project sharing views and the stat buttons are visible
but the click on these buttons are disabled.

Implementation details:
======================

- add `allow_project_sharing` option in project form view.
- create the webclient and routes.
- create own qweb bundle.
- add project sharing views.
- use the session to define the action and active_id
- create public fields for project sharing: in this step, we lists all field
names of the `project.task` model that we want to display for the
portal user. Two lists are created, one for only readable field names
and the other one for the writable field names.
Moreover, two properties are defined in the `project.task` model to use the
both lists.
- use `access_token` and check model in project sharing route: in this
step we check if the model is `project.project` and the `access_token`
is the one set in the project since we have the id of the project in the
params url.
- protect ORM methods: to avoid the portal user has accessed to too much
fields that he can, we have to check if all asked fields are in
the readable fields, if all fields are available for these users than we do the
method, otherwise we raise an `AccessError` exception.
- allow only `GET` method to enter project_sharing routes: the
`/my/project/<int:project_id>/project_sharing` route must only be
called with a `GET` method because this route is only used to have the template
to render the project sharing webclient.
- redirect the link in one of the sharing wizards to project sharing views if the portal user has an access
to the shared project and if the shared project has the Project Sharing feature enabled.
- only load `project.task` views for project sharing.
- remove longpolling in the project sharing.
- disable click on avatar to open the chat with the user or open the hr.employee form because these features are not authorize in the project sharing.
- remove the contextual action menu in the project sharing views
- add the chatter in the `project.task` form for the portal user with at least 'comment' access.
  This user could send message and see the public message like portal chatter.
- replace the basic portal views when the portal user selects project in `/my/projects` by the project sharing views if the `allow_project_sharing` is enabled for this project and if the portal user can access to the project sharing views of this project.

Why did we implement this feature in this way ?
====================================

We thought to create a `project.task.portal` model. This model had contained all fields that the portal user can read and edit (`readonly=True` if the portal user can just read the field).
We had 2 possibilities for this solution:

1. link this model with `project.task` either with inherits attribute in `project.task` or by creating a many2one field in the project.task.
    The problem with this solution is with `inherits`, we just copy all fields in `project.task` and not method, so we need to duplicate methods used in both models. Moreover, with `inherits` possbility, we have to create all records even if the Project Sharing feature is not used. If we took the other possibility (that is, create the Many2one field in `project.task` model) asks to create records when the `allow_project_sharing=True` in a project and also unlink/archive records when the Project Sharing feature is disabled in a project. The process is harder each time, the user enables/disables the feature. That's why we didn't do this solution.
2. the new model contains a sql view and we use the related field to create/edit tasks in project sharing views.
    We don't do this solution because it was a big change and increase the complexity. Moreover, if the sql view gives all tasks of shared projects, we could have a security issue because the portal user could have access to the tasks of shared project in which he didn't have any access.

Another solution that we thought before doing this feature was to create the kanban view in portal. We didn't do that because:
1. the render of this view will not be the same than the one in `/web`,
2. we didn't want to duplicate the kanban view,
3. we need to allow the portal to create/edit task.

task-2379518

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

closes odoo/odoo#73341

Signed-off-by: Yannick Tivisse (yti) <yti@odoo.com>
This commit is contained in:
Odoo's Mergebot
2021-08-26 18:24:47 +02:00
committed by GitHub
57 changed files with 2231 additions and 204 deletions
+4
View File
@@ -8,6 +8,8 @@ from . import wizard
from odoo import api, fields, SUPERUSER_ID, _
from odoo.addons.project import _check_exists_collaborators_for_project_sharing
def create_internal_project(cr, registry):
env = api.Environment(cr, SUPERUSER_ID, {})
@@ -28,3 +30,5 @@ def create_internal_project(cr, registry):
'project_id': task.project_id.id,
'task_id': task.id,
} for task in project_ids.task_ids.filtered(lambda t: t.company_id in admin.employee_ids.company_id)])
_check_exists_collaborators_for_project_sharing(env)
+2
View File
@@ -24,6 +24,7 @@ up a management by affair.
'data': [
'security/hr_timesheet_security.xml',
'security/ir.model.access.csv',
'security/ir.model.access.xml',
'data/digest_data.xml',
'views/hr_timesheet_views.xml',
'views/res_config_settings_views.xml',
@@ -37,6 +38,7 @@ up a management by affair.
'views/hr_views.xml',
'data/hr_timesheet_data.xml',
'wizard/project_task_create_timesheet_views.xml',
'views/project_sharing_views.xml',
],
'demo': [
'data/hr_timesheet_demo.xml',
+1
View File
@@ -7,5 +7,6 @@ from . import ir_http
from . import res_company
from . import res_config_settings
from . import project
from . import project_collaborator
from . import project_update
from . import uom
+23 -2
View File
@@ -7,6 +7,22 @@ from odoo import models, fields, api, _
from odoo.exceptions import UserError, ValidationError, RedirectWarning
PROJECT_TASK_READABLE_FIELDS = {
'allow_subtasks',
'allow_timesheets',
'analytic_account_active',
'effective_hours',
'encode_uom_in_days',
'planned_hours',
'progress',
'overtime',
'remaining_hours',
'subtask_effective_hours',
'subtask_planned_hours',
'timesheet_ids',
'total_hours_spent',
}
class Project(models.Model):
_inherit = "project.project"
@@ -167,7 +183,7 @@ class Task(models.Model):
_name = "project.task"
_inherit = "project.task"
analytic_account_active = fields.Boolean("Active Analytic Account", compute='_compute_analytic_account_active')
analytic_account_active = fields.Boolean("Active Analytic Account", compute='_compute_analytic_account_active', compute_sudo=True)
allow_timesheets = fields.Boolean("Allow timesheets", related='project_id.allow_timesheets', help="Timesheets can be logged on this task.", readonly=True)
remaining_hours = fields.Float("Remaining Hours", compute='_compute_remaining_hours', store=True, readonly=True, help="Total remaining time, can be re-estimated periodically by the assignee of the task.")
effective_hours = fields.Float("Hours Spent", compute='_compute_effective_hours', compute_sudo=True, store=True, help="Time spent on this task, excluding its sub-tasks.")
@@ -178,6 +194,10 @@ class Task(models.Model):
timesheet_ids = fields.One2many('account.analytic.line', 'task_id', 'Timesheets')
encode_uom_in_days = fields.Boolean(compute='_compute_encode_uom_in_days', default=lambda self: self._uom_in_days())
@property
def SELF_READABLE_FIELDS(self):
return super().SELF_READABLE_FIELDS | PROJECT_TASK_READABLE_FIELDS
def _uom_in_days(self):
return self.env.company.timesheet_encode_uom_id == self.env.ref('uom.product_uom_day')
@@ -279,7 +299,8 @@ class Task(models.Model):
def _fields_view_get(self, view_id=None, view_type='form', toolbar=False, submenu=False):
""" Set the correct label for `unit_amount`, depending on company UoM """
result = super(Task, self)._fields_view_get(view_id=view_id, view_type=view_type, toolbar=toolbar, submenu=submenu)
result['arch'] = self.env['account.analytic.line']._apply_timesheet_label(result['arch'])
# Use of sudo as the portal user doesn't have access to uom
result['arch'] = self.env['account.analytic.line'].sudo()._apply_timesheet_label(result['arch'])
if view_type in ['tree', 'pivot', 'graph'] and self.env.company.timesheet_encode_uom_id == self.env.ref('uom.product_uom_day'):
result['arch'] = self.env['account.analytic.line']._apply_time_label(result['arch'], related_model=self._name)
@@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models
class ProjectCollaborator(models.Model):
_inherit = 'project.collaborator'
@api.model
def _toggle_project_sharing_portal_rules(self, active):
super()._toggle_project_sharing_portal_rules(active)
# ir.model.access
access_timesheet_portal = self.env.ref('hr_timesheet.access_account_analytic_line_portal_user').sudo()
if access_timesheet_portal.active != active:
access_timesheet_portal.write({'active': active})
# ir.rule
timesheet_portal_ir_rule = self.env.ref('hr_timesheet.timesheet_line_rule_portal_user').sudo()
if timesheet_portal_ir_rule.active != active:
access_timesheet_portal.write({'active': active})
@@ -30,6 +30,25 @@
<field name="groups_id" eval="[(4,ref('group_timesheet_manager'))]"/>
</record>
<record id="timesheet_line_rule_portal_user" model="ir.rule">
<field name="name">account.analytic.line.timesheet.portal.user</field>
<field name="model_id" ref="analytic.model_account_analytic_line"/>
<field name="active">0</field>
<field name="domain_force">[
('project_id', '!=', False),
'|',
('project_id.message_partner_ids', 'child_of', [user.partner_id.commercial_partner_id.id]),
('task_id.message_partner_ids', 'child_of', [user.partner_id.commercial_partner_id.id]),
('project_id.privacy_visibility', '=', 'portal'),
('project_id.collaborator_ids.partner_id', 'in', [user.partner_id.id]),
]</field>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="True"/>
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
</record>
<record id="timesheet_line_rule_user" model="ir.rule">
<field name="name">account.analytic.line.timesheet.user</field>
<field name="model_id" ref="analytic.model_account_analytic_line"/>
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="access_account_analytic_line_portal_user" model="ir.model.access">
<field name="name">analytic.account.analytic.line.timesheet.portal.user</field>
<field name="model_id" ref="analytic.model_account_analytic_line"/>
<field name="group_id" ref="base.group_portal"/>
<field name="active">0</field>
<field name="perm_read">1</field>
<field name="perm_write">0</field>
<field name="perm_create">0</field>
<field name="perm_unlink">0</field>
</record>
</data>
</odoo>
@@ -0,0 +1,174 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="project_sharing_inherit_project_task_view_form" model="ir.ui.view">
<field name="name">project.sharing.project.task.view.form.inherit</field>
<field name="model">project.task</field>
<field name="inherit_id" ref="project.project_sharing_project_task_view_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='child_ids']/tree/field[@name='kanban_state']" position="after">
<field name="planned_hours" widget="timesheet_uom_no_toggle" sum="Initially Planned Hours" optional="hide"/>
<field name="effective_hours" widget="timesheet_uom" sum="Effective Hours" optional="hide"/>
<field name="remaining_hours" widget="timesheet_uom" sum="Remaining Hours" optional="hide" decoration-danger="progress &gt;= 100" decoration-warning="progress &gt;= 80 and progress &lt; 100"/>
<field name="progress" widget="progressbar" optional="hide"/>
</xpath>
<xpath expr="//notebook/page[@name='description_page']" position="after">
<field name="analytic_account_active" invisible="1"/>
<field name="allow_timesheets" invisible="1"/>
<field name="encode_uom_in_days" invisible="1"/>
<field name="subtask_count" invisible="1"/>
<page string="Timesheets" id="timesheets_tab" attrs="{'invisible': [('allow_timesheets', '=', False)]}">
<group>
<group>
<div class="o_td_label">
<label for="planned_hours" string="Initially Planned Hours" attrs="{'invisible': [('encode_uom_in_days', '=', True)]}"/>
<label for="planned_hours" string="Initially Planned Days" attrs="{'invisible': [('encode_uom_in_days', '=', False)]}"/>
</div>
<field name="planned_hours" widget="timesheet_uom_no_toggle" nolabel="1"/>
<div class="o_td_label" groups="project.group_subtask_project" attrs="{'invisible': ['|', ('allow_subtasks', '=', False), ('subtask_count', '=', 0)]}">
<label for="subtask_planned_hours" string="Sub-tasks Planned Hours" attrs="{'invisible': [('encode_uom_in_days', '=', True)]}"/>
<label for="subtask_planned_hours" string="Sub-tasks Planned Days" attrs="{'invisible': [('encode_uom_in_days', '=', False)]}"/>
</div>
<field name="subtask_planned_hours" widget="timesheet_uom_no_toggle" nolabel="1" groups="project.group_subtask_project" attrs="{'invisible': ['|', ('allow_subtasks', '=', False), ('subtask_count', '=', 0)]}"/>
</group>
<group>
<field name="progress" widget="progressbar"/>
</group>
</group>
<field name="timesheet_ids" mode="tree,kanban"
attrs="{'invisible': [('analytic_account_active', '=', False)]}">
<!-- TODO: see how to disable the _onRowclicked to not try to open form view -->
<tree string="Timesheet Activities" default_order="date" no_open="1">
<field name="date"/>
<field name="user_id" invisible="1"/>
<field name="employee_id" widget="many2one_avatar_employee" options="{'no_open_chat': True}"/>
<field name="name"/>
<field name="unit_amount" widget="timesheet_uom" decoration-danger="unit_amount &gt; 24"/>
</tree>
<kanban class="o_kanban_mobile">
<field name="date"/>
<field name="user_id" invisible="1"/>
<field name="employee_id" widget="many2one_avatar_employee" options="{'no_open_chat': True}"/>
<field name="name"/>
<field name="unit_amount" decoration-danger="unit_amount &gt; 24"/>
<field name="project_id"/>
<field name="task_id" invisible="1"/>
<templates>
<t t-name="kanban-box">
<div t-attf-class="oe_kanban_card oe_kanban_global_click">
<div class="row">
<div class="col-6">
<strong><span><t t-esc="record.employee_id.value"/></span></strong>
</div>
<div class="col-6 pull-right text-right">
<strong><t t-esc="record.date.value"/></strong>
</div>
</div>
<div class="row">
<div class="col-6 text-muted">
<span><t t-esc="record.name.value"/></span>
</div>
<div class="col-6">
<span class="pull-right text-right">
<field name="unit_amount" widget="float_time"/>
</span>
</div>
</div>
</div>
</t>
</templates>
</kanban>
</field>
<group attrs="{'invisible': [('analytic_account_active', '=', False)]}">
<group class="oe_subtotal_footer oe_right" name="project_hours">
<span>
<label class="font-weight-bold" for="effective_hours" string="Hours Spent" attrs="{'invisible': [('encode_uom_in_days', '=', True)]}"/>
<label class="font-weight-bold" for="effective_hours" string="Days Spent" attrs="{'invisible': [('encode_uom_in_days', '=', False)]}"/>
</span>
<field name="effective_hours" widget="timesheet_uom" nolabel="1"/>
<button name="action_view_subtask_timesheet" type="object" class="o_td_label o_form_label o_form_subtask_button oe_inline oe_link mr-0" attrs="{'invisible' : ['|', ('allow_subtasks', '=', False), ('subtask_effective_hours', '=', 0.0)]}">
<span class="text-nowrap" attrs="{'invisible' : [('encode_uom_in_days', '=', True)]}">Sub-tasks Hours Spent</span>
<span class="text-nowrap" attrs="{'invisible' : [('encode_uom_in_days', '=', False)]}">Sub-tasks Days Spent</span>
</button>
<field name="subtask_effective_hours" class="mt-2" widget="timesheet_uom"
attrs="{'invisible' : ['|', ('allow_subtasks', '=', False), ('subtask_effective_hours', '=', 0.0)]}" nolabel="1"/>
<span>
<label class="font-weight-bold" for="total_hours_spent" string="Total Hours"
attrs="{'invisible': ['|', '|', ('allow_subtasks', '=', False), ('subtask_effective_hours', '=', 0.0), ('encode_uom_in_days', '=', True)]}"/>
<label class="font-weight-bold" for="total_hours_spent" string="Total Days"
attrs="{'invisible': ['|', '|', ('allow_subtasks', '=', False), ('subtask_effective_hours', '=', 0.0), ('encode_uom_in_days', '=', False)]}"/>
</span>
<field name="total_hours_spent" widget="timesheet_uom" class="oe_subtotal_footer_separator" nolabel="1"
attrs="{'invisible' : ['|', ('allow_subtasks', '=', False), ('subtask_effective_hours', '=', 0.0)]}" />
<span>
<label class="font-weight-bold" for="remaining_hours" string="Remaining Hours"
attrs="{'invisible': ['|', ('planned_hours', '=', 0.0), ('encode_uom_in_days', '=', True)]}"/>
<label class="font-weight-bold" for="remaining_hours" string="Remaining Days"
attrs="{'invisible': ['|', ('planned_hours', '=', 0.0), ('encode_uom_in_days', '=', False)]}"/>
</span>
<field name="remaining_hours" widget="timesheet_uom" class="oe_subtotal_footer_separator"
attrs="{'invisible' : [('planned_hours', '=', 0.0)]}" nolabel="1"/>
</group>
</group>
</page>
</xpath>
</field>
</record>
<record id="project_sharing_kanban_inherit_project_task_view_kanban" model="ir.ui.view">
<field name="name">project.sharing.project.task.timesheet.kanban.inherited</field>
<field name="model">project.task</field>
<field name="inherit_id" ref="project.project_sharing_project_task_view_kanban"/>
<field name="arch" type="xml">
<templates position="before">
<field name="progress" />
<field name="remaining_hours" />
<field name="planned_hours" />
<field name="allow_timesheets"/>
<field name="encode_uom_in_days" invisible="1"/>
</templates>
<div class="oe_kanban_bottom_left" position="inside">
<t name="planned_hours" t-if="record.planned_hours.raw_value &gt; 0 and record.allow_timesheets.raw_value">
<t t-set="badge" t-value=""/>
<t t-set="badge" t-value="'badge-warning'" t-if="record.progress.raw_value &gt;= 80 and record.progress.raw_value &lt;= 100"/>
<t t-set="badge" t-value="'badge-danger'" t-if="record.remaining_hours.raw_value &lt; 0"/>
<t t-set="title" t-value="'Remaining days'" t-if="record.encode_uom_in_days.raw_value"/>
<t t-set="title" t-value="'Remaining hours'" t-else=""/>
<div t-attf-class="oe_kanban_align badge {{ badge }}" t-att-title="title">
<field name="remaining_hours" widget="timesheet_uom" />
</div>
</t>
</div>
</field>
</record>
<record id="project_sharing_inherit_project_task_view_tree" model="ir.ui.view">
<field name="name">project.task.tree.inherited</field>
<field name="model">project.task</field>
<field name="inherit_id" ref="project.project_sharing_project_task_view_tree" />
<field name="arch" type="xml">
<field name="user_id" position="after">
<field name="allow_subtasks" invisible="1"/>
<field name="allow_timesheets" invisible="1"/>
<field name="planned_hours" widget="timesheet_uom_no_toggle" sum="Initially Planned Hours" optional="hide" attrs="{'column_invisible': [('allow_timesheets', '=', False)]}"/>
<field name="effective_hours" widget="timesheet_uom" sum="Effective Hours" optional="show" attrs="{'column_invisible': [('allow_timesheets', '=', False)]}"/>
<field name="remaining_hours" widget="timesheet_uom" sum="Remaining Hours" optional="hide" decoration-danger="progress &gt;= 100" decoration-warning="progress &gt;= 80 and progress &lt; 100" attrs="{'column_invisible': [('allow_timesheets', '=', False)]}"/>
<field name="subtask_effective_hours" widget="timesheet_uom" attrs="{'column_invisible' : ['|', ('allow_subtasks', '=', False), ('allow_timesheets', '=', False)]}" optional="hide"/>
<field name="total_hours_spent" widget="timesheet_uom" attrs="{'invisible' : ['|', ('allow_subtasks', '=', False), ('allow_timesheets', '=', False)]}" optional="hide"/>
<field name="progress" widget="progressbar" optional="show" groups="hr_timesheet.group_hr_timesheet_user"/>
</field>
</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>
+7 -3
View File
@@ -30,6 +30,7 @@ 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;
},
//--------------------------------------------------------------------------
@@ -44,8 +45,11 @@ const M2XAvatarMixin = {
* @returns {Promise}
*/
async _openChat(params) {
const messaging = await Component.env.services.messaging.get();
return messaging.openChat(params);
if (!this.noOpenChat) {
const messaging = await Component.env.services.messaging.get();
return messaging.openChat(params);
}
return Promise.resolve();
},
};
@@ -68,7 +72,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({
@@ -304,4 +304,108 @@ 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();
});
});
+37 -22
View File
@@ -34,27 +34,7 @@ var PortalChatter = publicWidget.Widget.extend({
this.options = {};
this._super.apply(this, arguments);
// underscorize the camelcased option keys
_.each(options, function (val, key) {
self.options[_.str.underscored(key)] = val;
});
// set default options
this.options = _.defaults(this.options, {
'allow_composer': true,
'display_composer': false,
'csrf_token': odoo.csrf_token,
'message_count': 0,
'pager_step': 10,
'pager_scope': 5,
'pager_start': 1,
'is_user_public': true,
'is_user_employee': false,
'is_user_publisher': false,
'hash': false,
'pid': false,
'domain': [],
'two_columns': false,
});
this._setOptions(options);
this.set('messages', []);
this.set('message_count', this.options['message_count']);
@@ -135,6 +115,38 @@ var PortalChatter = publicWidget.Widget.extend({
// Private
//--------------------------------------------------------------------------
/**
* Set options
*
* @param {Array<string>} options: new options to set
*/
_setOptions: function (options) {
// underscorize the camelcased option keys
const defaultOptions = {
'allow_composer': true,
'display_composer': false,
'csrf_token': odoo.csrf_token,
'message_count': 0,
'pager_step': 10,
'pager_scope': 5,
'pager_start': 1,
'is_user_public': true,
'is_user_employee': false,
'is_user_publisher': false,
'hash': false,
'pid': false,
'domain': [],
'two_columns': false,
};
this.options = Object.entries(options).reduce(
(acc, [key, value]) => {
acc[_.str.underscored(key)] = value;
return acc;
},
defaultOptions);
},
/**
* Reloads chatter and message count after posting message
*
@@ -144,6 +156,9 @@ var PortalChatter = publicWidget.Widget.extend({
this.messageFetch();
this._reloadComposer();
},
_createComposerWidget: function () {
return new portalComposer.PortalComposer(this, this.options);
},
/**
* Destroy current composer widget and initialize and insert new widget
*
@@ -154,7 +169,7 @@ var PortalChatter = publicWidget.Widget.extend({
this._composer.destroy();
}
if (this.options.display_composer) {
this._composer = new portalComposer.PortalComposer(this, this.options);
this._composer = this._createComposerWidget();
await this._composer.appendTo(this.$('.o_portal_chatter_composer'));
}
},
+10 -7
View File
@@ -98,6 +98,15 @@ var PortalComposer = publicWidget.Widget.extend({
self.$sendButton.prop('disabled', false);
});
},
_prepareAttachmentData: function (file) {
return {
'name': file.name,
'file': file,
'res_id': this.options.res_id,
'res_model': this.options.res_model,
'access_token': this.options.token,
};
},
/**
* @private
* @returns {Promise}
@@ -109,13 +118,7 @@ var PortalComposer = publicWidget.Widget.extend({
return Promise.all(_.map(this.$fileInput[0].files, function (file) {
return new Promise(function (resolve, reject) {
var data = {
'name': file.name,
'file': file,
'res_id': self.options.res_id,
'res_model': self.options.res_model,
'access_token': self.options.token,
};
var data = self._prepareAttachmentData(file);
ajax.post('/portal/attachment/add', data).then(function (attachment) {
attachment.state = 'pending';
self.attachments.push(attachment);
+58 -32
View File
@@ -17,13 +17,26 @@ class PortalShare(models.TransientModel):
result['share_link'] = record.get_base_url() + record._get_share_url(redirect=True)
return result
@api.model
def _selection_target_model(self):
return [(model.model, model.name) for model in self.env['ir.model'].sudo().search([])]
res_model = fields.Char('Related Document Model', required=True)
res_id = fields.Integer('Related Document ID', required=True)
resource_ref = fields.Reference('_selection_target_model', 'Related Document', compute='_compute_resource_ref')
partner_ids = fields.Many2many('res.partner', string="Recipients", required=True)
note = fields.Text(help="Add extra content to display in the email")
share_link = fields.Char(string="Link", compute='_compute_share_link')
access_warning = fields.Text("Access warning", compute="_compute_access_warning")
@api.depends('res_model', 'res_id')
def _compute_resource_ref(self):
for wizard in self:
if wizard.res_model and wizard.res_model in self.env:
wizard.resource_ref = '%s,%s' % (wizard.res_model, wizard.res_id or 0)
else:
wizard.resource_ref = None
@api.depends('res_model', 'res_id')
def _compute_share_link(self):
for rec in self:
@@ -44,48 +57,61 @@ class PortalShare(models.TransientModel):
record = res_model.browse(rec.res_id)
rec.access_warning = record.access_warning
@api.model
def _get_note(self):
return self.env.ref('mail.mt_note')
def _send_public_link(self, note, partners=None):
if not partners:
partners = self.partner_ids
for partner in partners:
share_link = self.resource_ref.get_base_url() + self.resource_ref._get_share_url(redirect=True, pid=partner.id)
saved_lang = self.env.lang
self = self.with_context(lang=partner.lang)
template = self.env.ref('portal.portal_share_template', False)
self.resource_ref.message_post_with_view(template,
values={'partner': partner, 'note': self.note, 'record': self.resource_ref,
'share_link': share_link},
subject=_("You are invited to access %s", self.resource_ref.display_name),
subtype_id=note.id,
email_layout_xmlid='mail.mail_notification_light',
partner_ids=[(6, 0, partner.ids)])
self = self.with_context(lang=saved_lang)
def _send_signup_link(self, note, partners=None):
if not partners:
partners = self.partner_ids.filtered(lambda partner: not partner.user_ids)
for partner in partners:
# prepare partner for signup and send singup url with redirect url
partner.signup_get_auth_param()
share_link = partner._get_signup_url_for_action(action='/mail/view', res_id=self.res_id, model=self.res_model)[partner.id]
saved_lang = self.env.lang
self = self.with_context(lang=partner.lang)
template = self.env.ref('portal.portal_share_template', False)
self.resource_ref.message_post_with_view(template,
values={'partner': partner, 'note': self.note, 'record': self.resource_ref,
'share_link': share_link},
subject=_("You are invited to access %s", self.resource_ref.display_name),
subtype_id=note.id,
email_layout_xmlid='mail.mail_notification_light',
partner_ids=[(6, 0, partner.ids)])
self = self.with_context(lang=saved_lang)
def action_send_mail(self):
active_record = self.env[self.res_model].browse(self.res_id)
note = self.env.ref('mail.mt_note')
note = self._get_note()
signup_enabled = self.env['ir.config_parameter'].sudo().get_param('auth_signup.invitation_scope') == 'b2c'
if hasattr(active_record, 'access_token') and active_record.access_token or not signup_enabled:
if getattr(self.resource_ref, 'access_token', False) or not signup_enabled:
partner_ids = self.partner_ids
else:
partner_ids = self.partner_ids.filtered(lambda x: x.user_ids)
# if partner already user or record has access token send common link in batch to all user
for partner in self.partner_ids:
share_link = active_record.get_base_url() + active_record._get_share_url(redirect=True, pid=partner.id)
saved_lang = self.env.lang
self = self.with_context(lang=partner.lang)
template = self.env.ref('portal.portal_share_template', False)
active_record.message_post_with_view(template,
values={'partner': partner, 'note': self.note, 'record': active_record,
'share_link': share_link},
subject=_("You are invited to access %s", active_record.display_name),
subtype_id=note.id,
email_layout_xmlid='mail.mail_notification_light',
partner_ids=[(6, 0, partner.ids)])
self = self.with_context(lang=saved_lang)
self._send_public_link(note, partner_ids)
# when partner not user send individual mail with signup token
for partner in self.partner_ids - partner_ids:
# prepare partner for signup and send singup url with redirect url
partner.signup_get_auth_param()
share_link = partner._get_signup_url_for_action(action='/mail/view', res_id=self.res_id, model=self.model)[partner.id]
saved_lang = self.env.lang
self = self.with_context(lang=partner.lang)
template = self.env.ref('portal.portal_share_template', False)
active_record.message_post_with_view(template,
values={'partner': partner, 'note': self.note, 'record': active_record,
'share_link': share_link},
subject=_("You are invited to access %s", active_record.display_name),
subtype_id=note.id,
email_layout_xmlid='mail.mail_notification_light',
partner_ids=[(6, 0, partner.ids)])
self = self.with_context(lang=saved_lang)
self._send_signup_link(note, self.partner_ids - partner_ids)
# subscribe all recipients so that they receive future communication (better than
# using autofollow as more precise)
active_record.message_subscribe(partner_ids=self.partner_ids.ids)
self.resource_ref.message_subscribe(partner_ids=self.partner_ids.ids)
return {'type': 'ir.actions.act_window_close'}
+18
View File
@@ -6,3 +6,21 @@ from . import models
from . import report
from . import wizard
from . import populate
from odoo import api, SUPERUSER_ID
def _check_exists_collaborators_for_project_sharing(env):
""" Check if it exists at least a collaborator in a shared project
If it is the case we need to active the portal rules added only for this feature.
"""
collaborator = env['project.collaborator'].search([], limit=1)
if collaborator:
# Then we need to enable the access rights linked to project sharing for the portal user
env['project.collaborator']._toggle_project_sharing_portal_rules(True)
def _project_post_init(cr, registry):
env = api.Environment(cr, SUPERUSER_ID, {})
_check_exists_collaborators_for_project_sharing(env)
+31
View File
@@ -23,6 +23,7 @@
'data': [
'security/project_security.xml',
'security/ir.model.access.csv',
'security/ir.model.access.xml',
'data/digest_data.xml',
'report/project_report_views.xml',
'report/project_task_burndown_chart_report_views.xml',
@@ -32,12 +33,16 @@
'views/project_update_views.xml',
'views/project_update_templates.xml',
'views/project_project_stage_views.xml',
'wizard/project_share_wizard_views.xml',
'views/project_collaborator_views.xml',
'views/project_views.xml',
'views/res_partner_views.xml',
'views/res_config_settings_views.xml',
'views/mail_activity_views.xml',
'views/project_sharing_views.xml',
'views/project_portal_templates.xml',
'views/project_task_templates.xml',
'views/project_sharing_templates.xml',
'data/ir_cron_data.xml',
'data/mail_data.xml',
'data/mail_template_data.xml',
@@ -51,6 +56,7 @@
'installable': True,
'auto_install': False,
'application': True,
'post_init_hook': '_project_post_init',
'assets': {
'web.assets_backend': [
'project/static/src/css/project.css',
@@ -87,6 +93,31 @@
'web.assets_tests': [
'project/static/tests/tours/**/*',
],
'project.assets_qweb': [
('include', 'web.assets_qweb'),
'project/static/src/project_sharing/**/*.xml',
],
'project.webclient': [
('include', 'web.assets_backend'),
('remove', 'web/static/src/webclient/menu_service.js'),
# Remove Longpolling bus and packages needed this bus
('remove', 'bus/static/src/js/services/assets_watchdog_service.js'),
('remove', 'mail/static/src/services/messaging/messaging.js'),
('remove', 'mail/static/src/components/dialog_manager/dialog_manager.js'),
('remove', 'mail/static/src/services/dialog_service/dialog_service.js'),
('remove', 'mail/static/src/components/chat_window_manager/chat_window_manager.js'),
('remove', 'mail/static/src/services/chat_window_service/chat_window_service.js'),
'web/static/src/legacy/js/public/public_widget.js',
'portal/static/src/js/portal_chatter.js',
'portal/static/src/js/portal_composer.js',
'project/static/src/project_sharing/**/*.js',
'project/static/src/scss/project_sharing/*',
'web/static/src/start.js',
'web/static/src/legacy/legacy_setup.js',
],
},
'license': 'LGPL-3',
}
+1
View File
@@ -2,3 +2,4 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import portal
from . import project_sharing_chatter
+153 -25
View File
@@ -3,10 +3,9 @@
from collections import OrderedDict
from operator import itemgetter
from markupsafe import Markup
from odoo import http, _
from odoo import conf, http, _
from odoo.exceptions import AccessError, MissingError
from odoo.http import request
from odoo.addons.portal.controllers.portal import CustomerPortal, pager as portal_pager
@@ -14,6 +13,8 @@ from odoo.tools import groupby as groupbyelem
from odoo.osv.expression import OR
from odoo.addons.web.controllers.main import HomeStaticTemplateHelpers
class ProjectCustomerPortal(CustomerPortal):
@@ -28,11 +29,77 @@ class ProjectCustomerPortal(CustomerPortal):
# ------------------------------------------------------------
# My Project
# ------------------------------------------------------------
def _project_get_page_view_values(self, project, access_token, **kwargs):
values = {
'page_name': 'project',
'project': 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]
values.update(
date=date_begin,
date_end=date_end,
grouped_tasks=grouped_tasks,
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,
)
return self._get_page_view_values(project, access_token, values, 'my_projects_history', False, **kwargs)
@http.route(['/my/projects', '/my/projects/page/<int:page>'], type='http', auth="user", website=True)
@@ -80,31 +147,94 @@ 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, **kw):
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):
try:
project_sudo = self._document_check_access('project.project', project_id, access_token)
except (AccessError, MissingError):
return request.redirect('/my')
values = self._project_get_page_view_values(project_sudo, access_token, **kw)
if project_sudo.with_user(request.env.user)._check_project_sharing_access():
return request.render("project.project_sharing_portal", {'project_id': project_id})
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)
@http.route("/my/project/<int:project_id>/project_sharing", type="http", auth="user", methods=['GET'])
def render_project_backend_view(self, project_id):
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()
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 []
qweb_checksum = HomeStaticTemplateHelpers.get_qweb_templates_checksum(debug=request.session.debug, bundle="project.assets_qweb")
lang = user_context.get("lang")
translation_hash = request.env['ir.translation'].get_web_translations_hash(mods, lang)
cache_hashes = {
"qweb": qweb_checksum,
"translations": translation_hash,
}
project_company = project.company_id
session_info.update(
cache_hashes=cache_hashes,
action_name='project.project_sharing_project_task_action',
project_id=project.id,
user_companies={
'current_company': project_company.id,
'allowed_companies': {
project_company.id: {
'id': project_company.id,
'name': project_company.name,
},
},
},
)
return request.render(
'project.project_sharing_embed',
{'session_info': session_info},
)
@http.route('/my/project/<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)
except (AccessError, MissingError):
return request.redirect('/my')
Task = request.env['project.task']
if access_token:
Task = Task.sudo()
task = Task.search([('project_id', '=', project_id), ('id', '=', task_id)], limit=1)
task.sudo().attachment_ids.generate_access_token()
values = self._task_get_page_view_values(task, access_token, project=project_sudo, **kw)
values['project'] = project_sudo
return request.render("project.portal_my_task", values)
# ------------------------------------------------------------
# My Task
# ------------------------------------------------------------
def _task_get_page_view_values(self, task, access_token, **kwargs):
try:
project_accessible = bool(task.project_id.id and self._document_check_access('project.project', task.project_id.id))
except (AccessError, MissingError):
project_accessible = False
project = kwargs.get('project')
if project:
project_accessible = True
page_name = 'project_task'
history = 'my_project_tasks_history'
else:
page_name = 'task'
history = 'my_tasks_history'
try:
project_accessible = bool(task.project_id.id and self._document_check_access('project.project', task.project_id.id))
except (AccessError, MissingError):
project_accessible = False
values = {
'page_name': 'task',
'page_name': page_name,
'task': task,
'user': request.env.user,
'project_accessible': project_accessible,
}
return self._get_page_view_values(task, access_token, values, 'my_tasks_history', False, **kwargs)
return self._get_page_view_values(task, access_token, values, history, False, **kwargs)
def _task_get_searchbar_sortings(self):
return {
@@ -141,13 +271,6 @@ class ProjectCustomerPortal(CustomerPortal):
return order
return '%s, %s' % (field_name, order)
def _task_get_grouped_tasks(self, groupby, tasks):
if groupby:
grouped_tasks = [request.env['project.task'].concat(*g) for k, g in groupbyelem(tasks, itemgetter(groupby))]
else:
grouped_tasks = [tasks]
return grouped_tasks
def _task_get_searchbar_inputs(self):
values = {
'all': {'input': 'all', 'label': _('Search in All'), 'order': 1},
@@ -248,7 +371,11 @@ class ProjectCustomerPortal(CustomerPortal):
request.session['my_tasks_history'] = tasks.ids[:100]
groupby_mapping = self._task_get_groupby_mapping()
grouped_tasks = self._task_get_grouped_tasks(groupby_mapping.get(groupby), tasks)
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]
values.update({
'date': date_begin,
@@ -256,6 +383,7 @@ class ProjectCustomerPortal(CustomerPortal):
'grouped_tasks': grouped_tasks,
'page_name': 'task',
'default_url': '/my/tasks',
'task_url': 'task',
'pager': pager,
'searchbar_sortings': searchbar_sortings,
'searchbar_groupby': searchbar_groupby,
@@ -0,0 +1,106 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from werkzeug.exceptions import Forbidden
from odoo.http import request, route
from odoo.tools import consteq
from odoo.addons.portal.controllers.mail import PortalChatter
from .portal import ProjectCustomerPortal
class ProjectSharingChatter(PortalChatter):
def _check_project_access_and_get_token(self, project_id, res_model, res_id, token):
""" Check if the chatter in project sharing can be accessed
If the portal user is in the project sharing, then we do not have the access token of the task
but we can have the one of the project (if the user accessed to the project sharing views via the shared link).
So, we need to check if the chatter is for a task and if the res_id is a task
in the project shared. Then, if we had the project token and this one is the one in the project
then we return the token of the task to continue the portal chatter process.
If we do not have any token, then we need to check if the portal user is a follower of the project shared.
If it is the case, then we give the access token of the task.
"""
project_sudo = ProjectCustomerPortal._document_check_access(self, 'project.project', project_id, token)
can_access = project_sudo and res_model == 'project.task' and project_sudo.with_user(request.env.user)._check_project_sharing_access()
if token:
can_access &= consteq(project_sudo.access_token, token)
else:
can_access &= request.env.user.partner_id.commercial_partner_id in project_sudo.message_partner_ids
task = None
if can_access:
task = request.env['project.task'].sudo().search([('id', '=', res_id), ('project_id', '=', project_sudo.id)])
if not can_access or not task:
raise Forbidden()
return task[task._mail_post_token_field]
# ============================================================ #
# Note concerning the methods portal_chatter_(init/post/fetch)
# ============================================================ #
#
# When the project is shared to a portal user with the edit rights,
# he has the read/write access to the related tasks. So it could be
# possible to call directly the message_post method on a task.
#
# This change is considered as safe, as we only willingly expose
# records, for some assumed fields only, and this feature is
# optional and opt-in. (like the public employee model for example).
# It doesn't allow portal users to access other models, like
# a timesheet or an invoice.
#
# It could seem odd to use those routes, and converting the project
# access token into the task access token, as the user has actually
# access to the records.
#
# However, it has been decided that it was the less hacky way to
# achieve this, as:
#
# - We're reusing the existing routes, that convert all the data
# into valid arguments for the methods we use (message_post, ...).
# That way, we don't have to reinvent the wheel, duplicating code
# from mail/portal that surely will lead too desynchronization
# and inconsistencies over the time.
#
# - We don't define new routes, to do the exact same things than portal,
# considering that the portal user can use message_post for example
# because he has access to the record.
# Let's suppose that we remove this in a future development, those
# new routes won't be valid anymore.
#
# - We could have reused the mail widgets, as we already reuse the
# form/list/kanban views, etc. However, we only want to display
# the messages and allow to post. We don't need the next activities
# the followers system, etc. This required to override most of the
# mail.thread basic methods, without being sure that this would
# work with other installed applications or customizations
@route()
def portal_chatter_init(self, res_model, res_id, domain=False, limit=False, **kwargs):
project_sharing_id = kwargs.get('project_sharing_id')
if project_sharing_id:
# if there is a token in `kwargs` then it should be the access_token of the project shared
token = self._check_project_access_and_get_token(project_sharing_id, res_model, res_id, kwargs.get('token'))
if token:
del kwargs['project_sharing_id']
kwargs['token'] = token
return super().portal_chatter_init(res_model, res_id, domain=domain, limit=limit, **kwargs)
@route()
def portal_chatter_post(self, res_model, res_id, message, attachment_ids=None, attachment_tokens=None, **kw):
project_sharing_id = kw.get('project_sharing_id')
if project_sharing_id:
token = self._check_project_access_and_get_token(project_sharing_id, res_model, res_id, kw.get('token'))
if token:
del kw['project_sharing_id']
kw['token'] = token
return super().portal_chatter_post(res_model, res_id, message, attachment_ids=attachment_ids, attachment_tokens=attachment_tokens, **kw)
@route()
def portal_message_fetch(self, res_model, res_id, domain=False, limit=10, offset=0, **kw):
project_sharing_id = kw.get('project_sharing_id')
if project_sharing_id:
token = self._check_project_access_and_get_token(project_sharing_id, res_model, res_id, kw.get('token'))
if token:
kw['token'] = token
return super().portal_message_fetch(res_model, res_id, domain=domain, limit=limit, offset=offset, **kw)
+1
View File
@@ -6,6 +6,7 @@ from . import project_milestone
from . import project_project_stage
from . import project_task_recurrence
from . import project
from . import project_collaborator
from . import project_update
from . import res_config_settings
from . import res_partner
+207 -3
View File
@@ -7,13 +7,53 @@ from collections import defaultdict
from datetime import timedelta, datetime
from random import randint
from odoo import api, fields, models, tools, SUPERUSER_ID, _
from odoo.exceptions import UserError, ValidationError
from odoo import api, Command, fields, models, tools, SUPERUSER_ID, _
from odoo.exceptions import UserError, ValidationError, AccessError
from odoo.osv.expression import OR
from .project_task_recurrence import DAYS, WEEKS
from .project_update import STATUS_COLOR
PROJECT_TASK_READABLE_FIELDS = {
'id',
'active',
'description',
'priority',
'kanban_state_label',
'project_id',
'display_project_id',
'color',
'partner_is_company',
'commercial_partner_id',
'allow_subtasks',
'subtask_count',
'child_text',
'is_closed',
'email_from',
'create_date',
'write_date',
'company_id',
'displayed_image_id',
'display_name',
'priority',
}
PROJECT_TASK_WRITABLE_FIELDS = {
'name',
'partner_id',
'partner_email',
'user_id',
'date_deadline',
'tag_ids',
'sequence',
'stage_id',
'kanban_state',
'child_ids',
'parent_id',
}
class ProjectTaskType(models.Model):
_name = 'project.task.type'
_description = 'Task Stage'
@@ -240,6 +280,10 @@ class Project(models.Model):
allow_task_dependencies = fields.Boolean('Task Dependencies', default=lambda self: self.env.user.has_group('project.group_project_task_dependencies'))
tag_ids = fields.Many2many('project.tags', relation='project_project_project_tags_rel', string='Tags')
# Project Sharing fields
collaborator_ids = fields.One2many('project.collaborator', 'project_id', string='Collaborators', copy=False)
collaborator_count = fields.Integer('# Collaborators', compute='_compute_collaborator_count')
# rating fields
rating_request_deadline = fields.Datetime(compute='_compute_rating_request_deadline', store=True)
rating_active = fields.Boolean('Customer Ratings', default=lambda self: self.env.user.has_group('project.group_project_rating'))
@@ -352,6 +396,18 @@ class Project(models.Model):
else:
project.alias_value = "%s@%s" % (project.alias_name, project.alias_domain)
@api.depends('collaborator_ids', 'privacy_visibility')
def _compute_collaborator_count(self):
project_sharings = self.filtered(lambda project: project.privacy_visibility == 'portal')
collaborator_read_group = self.env['project.collaborator'].read_group(
[('project_id', 'in', project_sharings.ids)],
['project_id'],
['project_id'],
)
collaborator_count_by_project = {res['project_id'][0]: res['project_id_count'] for res in collaborator_read_group}
for project in self:
project.collaborator_count = collaborator_count_by_project.get(project.id, 0)
@api.model
def _map_tasks_default_valeus(self, task, project):
""" get the default value for the copied task on project duplication """
@@ -686,6 +742,35 @@ class Project(models.Model):
project.message_unsubscribe(partner_ids=portal_users.partner_id.ids)
project.mapped('tasks')._change_project_privacy_visibility()
# ---------------------------------------------------
# Project sharing
# ---------------------------------------------------
def _check_project_sharing_access(self):
self.ensure_one()
if self.privacy_visibility != 'portal':
return False
if self.env.user.has_group('base.group_portal'):
return self.env.user.partner_id in self.collaborator_ids.partner_id
return self.env.user.has_group('base.group_user')
def _add_collaborators(self, partners):
self.ensure_one()
user_group_id = self.env['ir.model.data']._xmlid_to_res_id('base.group_user')
all_collaborators = self.collaborator_ids.partner_id
new_collaborators = partners.filtered(
lambda partner:
partner not in all_collaborators
and (not partner.user_ids or user_group_id not in partner.user_ids[0].groups_id.ids)
)
if not new_collaborators:
# Then we have nothing to do
return
self.write({'collaborator_ids': [
Command.create({
'partner_id': collaborator.id,
}) for collaborator in new_collaborators],
})
class Task(models.Model):
_name = "project.task"
_description = "Task"
@@ -899,6 +984,14 @@ class Task(models.Model):
repeat_show_week = fields.Boolean(compute='_compute_repeat_visibility')
repeat_show_month = fields.Boolean(compute='_compute_repeat_visibility')
@property
def SELF_READABLE_FIELDS(self):
return PROJECT_TASK_READABLE_FIELDS | self.SELF_WRITABLE_FIELDS
@property
def SELF_WRITABLE_FIELDS(self):
return PROJECT_TASK_WRITABLE_FIELDS
@api.constrains('depend_on_ids')
def _check_no_cyclic_dependencies(self):
if not self._check_m2m_recursion('depend_on_ids'):
@@ -1185,6 +1278,22 @@ class Task(models.Model):
# ------------------------------------------------
# CRUD overrides
# ------------------------------------------------
@api.model
def fields_get(self, allfields=None, attributes=None):
fields = super().fields_get(allfields=allfields, attributes=attributes)
if not self.env.user.has_group('base.group_portal'):
return fields
readable_fields = self.SELF_READABLE_FIELDS
public_fields = {field_name: description for field_name, description in fields.items() if field_name in readable_fields}
writable_fields = self.SELF_WRITABLE_FIELDS
for field_name, description in public_fields.items():
if field_name not in writable_fields and not description.get('readonly', False):
# If the field is not in Writable fields and it is not readonly then we force the readonly to True
description['readonly'] = True
return public_fields
@api.model
def default_get(self, default_fields):
vals = super(Task, self).default_get(default_fields)
@@ -1217,10 +1326,76 @@ class Task(models.Model):
return vals
def _ensure_fields_are_accessible(self, fields, operation='read', check_group_user=True):
"""" ensure all fields are accessible by the current user
This method checks if the portal user can access to all fields given in parameter.
By default, it checks if the current user is a portal user and then checks if all fields are accessible for this user.
:param fields: list of fields to check if the current user can access.
:param operation: contains either 'read' to check readable fields or 'write' to check writable fields.
:param check_group_user: contains boolean value.
- True, if the method has to check if the current user is a portal one.
- False if we are sure the user is a portal user,
"""
assert operation in ('read', 'write'), 'Invalid operation'
if fields and (not check_group_user or self.env.user.has_group('base.group_portal')) and not self.env.su:
unauthorized_fields = set(fields) - (self.SELF_READABLE_FIELDS if operation == 'read' else self.SELF_WRITABLE_FIELDS)
if unauthorized_fields:
raise AccessError(_('You cannot %s %s fields in task.', operation if operation == 'read' else '%s on' % operation, ', '.join(unauthorized_fields)))
def read(self, fields=None, load='_classic_read'):
self._ensure_fields_are_accessible(fields)
return super(Task, self).read(fields=fields, load=load)
@api.model
def read_group(self, domain, fields, groupby, offset=0, limit=None, orderby=False, lazy=True):
fields_list = ([f.split(':')[0] for f in fields] or [])
if groupby:
fields_list += [groupby] if isinstance(groupby, str) else groupby
if domain:
fields_list += [term[0].split('.')[0] for term in domain if isinstance(term, (tuple, list))]
self._ensure_fields_are_accessible(fields_list)
return super(Task, self).read_group(domain, fields, groupby, offset=offset, limit=limit, orderby=orderby, lazy=lazy)
@api.model
def _search(self, args, offset=0, limit=None, order=None, count=False, access_rights_uid=None):
fields_list = {term[0] for term in args if isinstance(term, (tuple, list))}
self._ensure_fields_are_accessible(fields_list)
return super(Task, self)._search(args, offset=offset, limit=limit, order=order, count=count, access_rights_uid=access_rights_uid)
def mapped(self, func):
# Note: This will protect the filtered method too
if func and isinstance(func, str):
fields_list = func.split('.')
self._ensure_fields_are_accessible(fields_list)
return super(Task, self).mapped(func)
def filtered_domain(self, domain):
fields_list = [term[0] for term in domain if isinstance(term, (tuple, list))]
self._ensure_fields_are_accessible(fields_list)
return super(Task, self).filtered_domain(domain)
def copy_data(self, default=None):
defaults = super().copy_data(default=default)
return [{k: v for k, v in default.items() if k in self.SELF_READABLE_FIELDS} for default in defaults]
@api.model
def _ensure_portal_user_can_write(self, fields):
for field in fields:
if field not in self.SELF_WRITABLE_FIELDS:
raise AccessError(_('You have not write access of %s field.') % field)
@api.model_create_multi
def create(self, vals_list):
is_portal_user = self.env.user.has_group('base.group_portal')
if is_portal_user:
self.check_access_rights('create')
default_stage = dict()
for vals in vals_list:
if is_portal_user:
self._ensure_fields_are_accessible(vals.keys(), operation='write', check_group_user=False)
project_id = vals.get('project_id') or self.env.context.get('default_project_id')
if not vals.get('parent_id'):
# 1) We must initialize display_project_id to follow project_id if there is no parent_id
@@ -1252,13 +1427,36 @@ class Task(models.Model):
rec_values['next_recurrence_date'] = fields.Datetime.today()
recurrence = self.env['project.task.recurrence'].create(rec_values)
vals['recurrence_id'] = recurrence.id
tasks = super().create(vals_list)
# The sudo is required for a portal user as the record creation
# requires the read access on other models, as mail.template
# in order to compute the field tracking
if is_portal_user:
ctx = {
key: value for key, value in self.env.context.items()
if key == 'default_project_id' \
or not key.startswith('default_') \
or key[8:] in self.SELF_WRITABLE_FIELDS
}
self = self.with_context(ctx).sudo()
tasks = super(Task, self).create(vals_list)
if is_portal_user:
# since we use sudo to create tasks, we need to check
# if the portal user could really create the tasks based on the ir rule.
tasks.with_user(self.env.user).check_access_rule('create')
for task in tasks:
if task.project_id.privacy_visibility == 'portal':
task._portal_ensure_token()
return tasks
def write(self, vals):
portal_can_write = False
if self.env.user.has_group('base.group_portal') and not self.env.su:
# Check if all fields in vals are in SELF_WRITABLE_FIELDS
self._ensure_fields_are_accessible(vals.keys(), operation='write', check_group_user=False)
self.check_access_rights('write')
self.check_access_rule('write')
portal_can_write = True
now = fields.Datetime.now()
if 'parent_id' in vals and vals['parent_id'] in self.ids:
raise UserError(_("Sorry. You can't set a task as its parent task."))
@@ -1302,6 +1500,12 @@ class Task(models.Model):
recurrence_domain = [('recurrence_id', 'in', self.recurrence_id.ids)]
tasks |= self.env['project.task'].search(recurrence_domain)
# The sudo is required for a portal user as the record update
# requires the write access on others models, as rating.rating
# in order to keep the same name than the task.
if portal_can_write:
tasks = tasks.sudo()
result = super(Task, tasks).write(vals)
# rating on stage
if 'stage_id' in vals and vals.get('stage_id'):
@@ -0,0 +1,57 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
class ProjectCollaborator(models.Model):
_name = 'project.collaborator'
_description = 'Collaborators in project shared'
project_id = fields.Many2one('project.project', 'Project Shared', domain=[('privacy_visibility', '=', 'portal')], required=True, readonly=True)
partner_id = fields.Many2one('res.partner', 'Collaborator', required=True, readonly=True)
_sql_constraints = [
('unique_collaborator', 'UNIQUE(project_id, partner_id)', 'A collaborator cannot be selected more than once in the project sharing access. Please remove duplicate(s) and try again.'),
]
def name_get(self):
collaborator_search_read = self.search_read([('id', 'in', self.ids)], ['id', 'project_id', 'partner_id'])
return [(collaborator['id'], '%s - %s' % (collaborator['project_id'][1], collaborator['partner_id'][1])) for collaborator in collaborator_search_read]
@api.model_create_multi
def create(self, vals_list):
collaborator = self.env['project.collaborator'].search([], limit=1)
project_collaborators = super().create(vals_list)
non_authenticated_collaborator = project_collaborators.partner_id.filtered(lambda partner: not partner.user_ids)
non_authenticated_collaborator._create_portal_users()
if not collaborator:
self._toggle_project_sharing_portal_rules(True)
return project_collaborators
def unlink(self):
res = super().unlink()
# Check if it remains at least a collaborator in all shared projects.
collaborator = self.env['project.collaborator'].search([], limit=1)
if not collaborator: # then disable the project sharing feature
self._toggle_project_sharing_portal_rules(False)
return res
@api.model
def _toggle_project_sharing_portal_rules(self, active):
""" Enable/disable project sharing feature
When the first collaborator is added in the model then we need to enable the feature.
In the inverse case, if no collaborator is stored in the model then we disable the feature.
To enable/disable the feature, we just need to enable/disable the ir.model.access and ir.rule
added to portal user that we do not want to give when we know the project sharing is unused.
:param active: contains boolean value, True to enable the project sharing feature, otherwise we disable the feature.
"""
access_project_sharing_portal = self.env.ref('project.access_project_sharing_task_portal').sudo()
if access_project_sharing_portal.active != active:
access_project_sharing_portal.write({'active': active})
task_portal_ir_rule = self.env.ref('project.project_task_rule_portal_project_sharing').sudo()
if task_portal_ir_rule.active != active:
task_portal_ir_rule.write({'active': active})
+17
View File
@@ -2,6 +2,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
from odoo.tools import email_normalize
class ResPartner(models.Model):
@@ -28,3 +29,19 @@ class ResPartner(models.Model):
if partner in self:
partner.task_count += group['partner_id_count']
partner = partner.parent_id
def _create_portal_users(self):
partners_without_user = self.filtered(lambda partner: not partner.user_ids)
if not partners_without_user:
return self.env['res.users']
created_users = self.env['res.users']
for partner in partners_without_user:
created_users += self.env['res.users'].with_context(no_reset_password=True)._create_user_from_template({
'email': email_normalize(partner.email),
'login': email_normalize(partner.email),
'partner_id': partner.id,
'company_id': self.env.company.id,
'company_ids': [(6, 0, self.env.company.ids)],
'active': True,
})
return created_users
@@ -36,3 +36,6 @@ access_project_milestone_user,project.milestone.user,model_project_milestone,bas
access_project_milestone_portal,project.milestone.portal,model_project_milestone,base.group_portal,1,0,0,0
access_project_milestone_project_user,project.milestone.project.user,model_project_milestone,project.group_project_user,1,0,0,0
access_project_milestone_project_manager,project.milestone.project.manager,model_project_milestone,project.group_project_manager,1,1,1,1
access_project_collaborator_manager,project.collaborator.manager,model_project_collaborator,project.group_project_manager,1,1,1,1
access_project_collaborator_portal,project.collaborator.portal,model_project_collaborator,base.group_portal,1,0,0,0
access_project_share_manager,project.share.wizard.manager,model_project_share_wizard,project.group_project_manager,1,1,1,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
36 access_project_milestone_portal project.milestone.portal model_project_milestone base.group_portal 1 0 0 0
37 access_project_milestone_project_user project.milestone.project.user model_project_milestone project.group_project_user 1 0 0 0
38 access_project_milestone_project_manager project.milestone.project.manager model_project_milestone project.group_project_manager 1 1 1 1
39 access_project_collaborator_manager project.collaborator.manager model_project_collaborator project.group_project_manager 1 1 1 1
40 access_project_collaborator_portal project.collaborator.portal model_project_collaborator base.group_portal 1 0 0 0
41 access_project_share_manager project.share.wizard.manager model_project_share_wizard project.group_project_manager 1 1 1 0
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="access_project_sharing_task_portal" model="ir.model.access">
<field name="name">project_sharing_task_portal</field>
<field name="model_id" ref="model_project_task"/>
<field name="group_id" ref="base.group_portal"/>
<field name="active">0</field>
<field name="perm_read">0</field>
<field name="perm_write">1</field>
<field name="perm_create">1</field>
<field name="perm_unlink">0</field>
</record>
</data>
</odoo>
+35 -4
View File
@@ -119,19 +119,50 @@
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
</record>
<record id="project_collaborator_rule_portal" model="ir.rule">
<field name="name">Project/Collaborator: portal users: can only see his own collobaroration in shared projects</field>
<field name="model_id" ref="project.model_project_collaborator"/>
<field name="domain_force">[
('project_id.privacy_visibility', '=', 'portal'),
('partner_id', '=', user.partner_id.id),
]</field>
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
</record>
<record id="project_task_rule_portal" model="ir.rule">
<field name="name">Project/Task: portal users: (portal and following project) or (portal and following task)</field>
<field name="model_id" ref="project.model_project_task"/>
<field name="domain_force">[
('project_id.privacy_visibility', '=', 'portal'),
('active', '=', True),
'|',
'&amp;',
('project_id.privacy_visibility', '=', 'portal'),
('project_id.message_partner_ids', 'child_of', [user.partner_id.commercial_partner_id.id]),
('message_partner_ids', 'child_of', [user.partner_id.commercial_partner_id.id]),
]</field>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
</record>
<record id="project_task_rule_portal_project_sharing" model="ir.rule">
<field name="name">Project/Task: portal users: portal user can edit with project sharing feature</field>
<field name="model_id" ref="project.model_project_task"/>
<field name="active">0</field>
<field name="domain_force">[
('project_id.privacy_visibility', '=', 'portal'),
('active', '=', True),
'|',
('project_id.message_partner_ids', 'child_of', [user.partner_id.commercial_partner_id.id]),
'&amp;',
('project_id.privacy_visibility', '=', 'portal'),
('message_partner_ids', 'child_of', [user.partner_id.commercial_partner_id.id]),
('project_id.collaborator_ids.partner_id', 'in', [user.partner_id.id]),
]</field>
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
<field name="perm_read" eval="False"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="False"/>
</record>
<record model="ir.rule" id="update_comp_rule">
@@ -0,0 +1,34 @@
/** @odoo-module **/
import { PortalChatter } from 'portal.chatter';
import Composer from './composer';
export default PortalChatter.extend({
messageFetch(domain) {
if (!this.options.res_id) {
return Promise.resolve();
}
return this._super.apply(this, arguments);
},
_chatterInit() {
if (!this.options.res_id) {
this.result = { messages: [] };
return Promise.resolve();
}
return this._super.apply(this, arguments);
},
_messageFetchPrepareParams() {
const data = this._super.apply(this, arguments);
if (this.options.project_sharing_id) {
data.project_sharing_id = this.options.project_sharing_id;
}
return data;
},
_createComposerWidget() {
return new Composer(this, this.options);
},
update(props) {
this._setOptions(props);
this._reloadChatterContent();
},
});
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-extend="portal.chatter_messages">
<t t-jquery="t[t-as='message']" t-operation="attributes">
<attribute name="t-foreach">!!widget.options.res_id &amp;&amp; widget.get('messages') || []</attribute>
</t>
</t>
</templates>
@@ -0,0 +1,17 @@
/** @odoo-module **/
import { PortalComposer } from 'portal.composer';
export default PortalComposer.extend({
_prepareAttachmentData() {
const data = this._super.apply(this, arguments);
const newData = {};
if (this.options.display_composer && typeof this.options.display_composer == 'string') {
// then we should have the access_token of the task
newData.access_token = this.options.display_composer;
} else {
newData.project_sharing_id = this.options.project_sharing_id;
}
return Object.assign(data, newData);
},
});
@@ -0,0 +1,5 @@
/** @odoo-module **/
import { startWebClient } from '@web/start';
import { ProjectSharingWebClient } from './project_sharing';
startWebClient(ProjectSharingWebClient);
@@ -0,0 +1,73 @@
/** @odoo-module **/
import { registry } from '@web/core/registry';
import { useBus, useEffect, useService } from '@web/core/utils/hooks';
import { ActionContainer } from '@web/webclient/actions/action_container';
import { MainComponentsContainer } from "@web/core/main_components_container";
import { useOwnDebugContext } from "@web/core/debug/debug_context";
import { DebugMenu } from "@web/core/debug/debug_menu";
import { ErrorHandler, NotUpdatable } from "@web/core/utils/components";
import { session } from '@web/session';
const { Component } = owl;
export class ProjectSharingWebClient extends Component {
setup() {
window.parent.document.body.style.margin = "0"; // remove the margin in the parent body
this.actionService = useService('action');
this.user = useService("user");
useService("legacy_service_provider");
useOwnDebugContext({ categories: ["default"] });
if (this.env.debug) {
registry.category("systray").add(
"web.debug_mode_menu",
{
Component: DebugMenu,
},
{ sequence: 100 }
);
}
useBus(this.env.bus, "ACTION_MANAGER:UI-UPDATED", (mode) => {
if (mode !== "new") {
this.el.classList.toggle("o_fullscreen", mode === "fullscreen");
}
});
useEffect(
() => {
this._showView();
},
() => []
);
}
mounted() { }
handleComponentError(error, C) {
// remove the faulty component
this.Components.splice(this.Components.indexOf(C), 1);
/**
* we rethrow the error to notify the user something bad happened.
* We do it after a tick to make sure owl can properly finish its
* rendering
*/
Promise.resolve().then(() => {
throw error;
});
}
async _showView() {
const { action_name, project_id } = session;
await this.actionService.doAction(
action_name,
{
clearBreadcrumbs: true,
additionalContext: {
active_id: project_id,
}
}
);
}
}
ProjectSharingWebClient.components = { ActionContainer, ErrorHandler, NotUpdatable, MainComponentsContainer };
ProjectSharingWebClient.template = 'project.ProjectSharingWebClient';
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<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>
</t>
</templates>
@@ -0,0 +1,8 @@
/** @odoo-module **/
import FormController from 'web.FormController';
import BasicController from 'web.BasicController';
export default FormController.extend({
_getActionMenuItems: BasicController.prototype._getActionMenuItems,
});
@@ -0,0 +1,54 @@
/** @odoo-module **/
import FormRenderer from 'web.FormRenderer';
import Chatter from '@project/project_sharing/components/chatter';
import { session } from '@web/session';
export default FormRenderer.extend({
_makeChatterContainerComponent() {
const props = this._makeChatterContainerProps();
this._chatterContainerComponent = new Chatter(this, props);
},
_makeChatterContainerProps() {
// FIXME: perhaps check if we have a parent in the window ?
// Call the parent window to get the url displayed in the browser since we are in an iframe
const query = new URLSearchParams(window.parent.location.search);
return {
token: query.get('access_token') || '',
res_model: 'project.task',
pid: '',
hash: '',
res_id: this.state.res_id,
pager_step: 10,
allow_composer: !!this.state.res_id,
two_columns: false,
project_sharing_id: session.project_id,
};
},
_makeChatterContainerTarget() {
const $el = $('<div class="o_FormRenderer_chatterContainer"/>');
this._chatterContainerTarget = $el[0];
return $el;
},
_mountChatterContainerComponent() {
this._chatterContainerComponent.appendTo(this._chatterContainerTarget);
},
_renderNode(node) {
if (node.tag === 'div' && node.attrs.class === 'oe_project_sharing_chatter') {
let isVisible = true;
if (node.attrs.modifiers && node.attrs.modifiers.invisible) {
const record = this._getRecord(this.state.id);
if (record) {
isVisible = !record.evalModifiers(node.attrs.modifiers).invisible;
}
}
if (isVisible) {
if (this._isFromFormViewDialog) {
return $('<div/>');
}
return this._makeChatterContainerTarget();
}
}
return this._super(...arguments);
},
});
@@ -0,0 +1,12 @@
/** @odoo-module **/
import FormView from 'web.FormView';
import Controller from './controller';
import Renderer from './renderer';
export default FormView.extend({
config: _.extend({}, FormView.prototype.config, {
Controller,
Renderer,
}),
});
@@ -0,0 +1,8 @@
/** @odoo-module **/
import ListController from 'web.ListController';
import BasicController from 'web.BasicController';
export default ListController.extend({
_getActionMenuItems: BasicController.prototype._getActionMenuItems,
});
@@ -0,0 +1,10 @@
/** @odoo-module **/
import ListView from 'web.ListView';
import Controller from './controller';
export default ListView.extend({
config: Object.assign({}, ListView.prototype.config, {
Controller,
}),
});
@@ -0,0 +1,10 @@
/** @odoo-module **/
import ViewRegistry from 'web.view_registry';
import FormView from './form/view';
import ListView from './list/view';
ViewRegistry
.add('form', FormView)
.add('list', ListView);
@@ -0,0 +1,44 @@
.o_FormRenderer_chatterContainer {
display: flex;
background-color: $white;
border-color: $border-color;
height: fit-content;
.o_portal_chatter {
width: 100%;
.o_portal_chatter_header {
text-align: center;
vertical-align: middle;
}
div.o_portal_chatter_composer,
div.o_portal_chatter_messages {
div.media {
gap: 10px;
.o_portal_chatter_attachments {
margin-bottom: 1rem;
}
}
}
}
&.o-aside {
flex-direction: column;
.o_portal_chatter {
.o_portal_chatter_header {
padding-top: 1rem;
padding-bottom: 2px;
}
.o_portal_chatter_composer, .o_portal_chatter_messages {
margin-left: 1rem;
margin-right: 1rem;
}
}
}
}
+1
View File
@@ -6,6 +6,7 @@ from . import test_project_base
from . import test_project_config
from . import test_project_flow
from . import test_project_recurrence
from . import test_project_sharing
from . import test_project_subtasks
from . import test_project_ui
from . import test_project_update_access_rights
@@ -0,0 +1,175 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import Command
from odoo.exceptions import AccessError
from odoo.tests import tagged
from odoo.tests.common import Form
from .test_project_base import TestProjectCommon
class TestProjectSharingCommon(TestProjectCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
project_sharing_stages_vals_list = [
(0, 0, {'name': 'To Do', 'sequence': 1}),
(0, 0, {'name': 'Done', 'sequence': 10}),
]
cls.partner_portal = cls.env['res.partner'].create({
'name': 'Chell Gladys',
'email': 'chell@gladys.portal',
'company_id': False,
'user_ids': [Command.link(cls.user_portal.id)]})
cls.project_cows = cls.env['project.project'].with_context({'mail_create_nolog': True}).create({
'name': 'Cows',
'privacy_visibility': 'portal',
'alias_name': 'project+cows',
'type_ids': project_sharing_stages_vals_list,
})
cls.project_portal = cls.env['project.project'].with_context({'mail_create_nolog': True}).create({
'name': 'Portal',
'privacy_visibility': 'portal',
'alias_name': 'project+portal',
'partner_id': cls.user_portal.partner_id.id,
'type_ids': project_sharing_stages_vals_list,
})
cls.task_cow = cls.env['project.task'].with_context({'mail_create_nolog': True}).create({
'name': 'Cow UserTask',
'user_id': cls.user_projectuser.id,
'project_id': cls.project_cows.id,
})
cls.task_portal = cls.env['project.task'].with_context({'mail_create_nolog': True}).create({
'name': 'Portal UserTask',
'user_id': cls.user_projectuser.id,
'project_id': cls.project_portal.id,
})
def get_project_sharing_form_view(self, record, with_user=None):
return Form(
record.with_user(with_user or self.env.user),
view="project.project_sharing_project_task_view_form"
)
@tagged('project_sharing')
class TestProjectSharing(TestProjectSharingCommon):
def test_project_share_wizard(self):
""" Test Project Share Wizard
Test Cases:
==========
1) Create the wizard record
2) Check if no access rights are given to a portal user
3) Add access rights to a portal user
"""
project_share_wizard = self.env['project.share.wizard'].create({
'res_model': 'project.project',
'res_id': self.project_portal.id,
'access_mode': 'edit',
})
self.assertFalse(project_share_wizard.partner_ids, 'No collaborator should be in the wizard.')
self.assertFalse(self.project_portal.with_user(self.user_portal)._check_project_sharing_access(), 'The portal user should not have accessed in project sharing views.')
project_share_wizard.write({'partner_ids': [Command.link(self.user_portal.partner_id.id)]})
project_share_wizard.action_send_mail()
self.assertEqual(len(self.project_portal.collaborator_ids), 1, 'The access right added in project share wizard should be added in the project when the user confirm the access in the wizard.')
self.assertDictEqual({
'partner_id': self.project_portal.collaborator_ids.partner_id,
'project_id': self.project_portal.collaborator_ids.project_id,
}, {
'partner_id': self.user_portal.partner_id,
'project_id': self.project_portal,
}, 'The access rights added should be the read access for the portal project for Chell Gladys.')
self.assertTrue(self.project_portal.with_user(self.user_portal)._check_project_sharing_access(), 'The portal user should have read access to the portal project with project sharing feature.')
def test_project_sharing_access(self):
""" Check if the different user types can access to project sharing feature as expected. """
with self.assertRaises(AccessError, msg='The public user should not have any access to project sharing feature of the portal project.'):
self.project_portal.with_user(self.user_public)._check_project_sharing_access()
self.assertTrue(self.project_portal.with_user(self.user_projectuser)._check_project_sharing_access(), 'The internal user should have all accesses to project sharing feature of the portal project.')
self.assertFalse(self.project_portal.with_user(self.user_portal)._check_project_sharing_access(), 'The portal user should not have any access to project sharing feature of the portal project.')
self.project_portal.write({'collaborator_ids': [Command.create({'partner_id': self.user_portal.partner_id.id})]})
self.assertTrue(self.project_portal.with_user(self.user_portal)._check_project_sharing_access(), 'The portal user can access to project sharing feature of the portal project.')
def test_create_task_in_project_sharing(self):
""" Test when portal user creates a task in project sharing views.
Test Cases:
==========
1) Give the 'read' access mode to a portal user in a project and try to create task with this user.
2) Give the 'comment' access mode to a portal user in a project and try to create task with this user.
3) Give the 'edit' access mode to a portal user in a project and try to create task with this user.
3.1) Try to change the project of the new task with this user.
"""
# 1) Give the 'read' access mode to a portal user in a project and try to create task with this user.
with self.assertRaises(AccessError, msg="Should not accept the portal user create a task in the project when he has not the edit access right."):
with self.get_project_sharing_form_view(self.env['project.task'].with_context({'tracking_disable': True, 'default_project_id': self.project_portal.id}), self.user_portal) as form:
form.name = 'Test'
task = form.save()
self.project_portal.write({
'collaborator_ids': [
Command.create({'partner_id': self.user_portal.partner_id.id}),
],
})
with self.get_project_sharing_form_view(self.env['project.task'].with_context({'tracking_disable': True, 'default_project_id': self.project_portal.id}), self.user_portal) as form:
form.name = 'Test'
task = form.save()
self.assertEqual(task.name, 'Test')
self.assertEqual(task.project_id, self.project_portal)
self.assertEqual(task.user_id, self.user_portal)
# 3.1) Try to change the project of the new task with this user.
with self.assertRaises(AssertionError, msg="Should not accept the portal user changes the project of the task."):
form.project_id = self.project_cows
task = form.save()
def test_edit_task_in_project_sharing(self):
""" Test when portal user creates a task in project sharing views.
Test Cases:
==========
1) Give the 'read' access mode to a portal user in a project and try to edit task with this user.
2) Give the 'comment' access mode to a portal user in a project and try to edit task with this user.
3) Give the 'edit' access mode to a portal user in a project and try to create task with this user.
3.1) Try to change the project of the new task with this user.
"""
# 1) Give the 'read' access mode to a portal user in a project and try to create task with this user.
with self.assertRaises(AccessError, msg="Should not accept the portal user create a task in the project when he has not the edit access right."):
with self.get_project_sharing_form_view(self.task_cow.with_context({'tracking_disable': True, 'default_project_id': self.project_cows.id}), self.user_portal) as form:
form.name = 'Test'
task = form.save()
project_share_wizard = self.env['project.share.wizard'].create({
'access_mode': 'edit',
'res_model': 'project.project',
'res_id': self.project_cows.id,
'partner_ids': [
Command.link(self.user_portal.partner_id.id),
],
})
project_share_wizard.action_send_mail()
with self.get_project_sharing_form_view(self.task_cow.with_context({'tracking_disable': True, 'default_project_id': self.project_cows.id, 'uid': self.user_portal.id}), self.user_portal) as form:
form.name = 'Test'
task = form.save()
self.assertEqual(task.name, 'Test')
self.assertEqual(task.project_id, self.project_cows)
# 3.1) Try to change the project of the new task with this user.
with self.assertRaises(AssertionError, msg="Should not accept the portal user changes the project of the task."):
form.project_id = self.project_portal
task = form.save()
# 3.2) Create a sub-task
with form.child_ids.new() as subtask_form:
subtask_form.name = 'Test Subtask'
with self.assertRaises(AssertionError, msg="Should not accept the portal user changes the project of the task."):
subtask_form.display_project_id = self.project_portal
form.save()
self.assertEqual(task.child_ids.name, 'Test Subtask')
self.assertEqual(task.child_ids.project_id, self.project_cows)
self.assertEqual(task.child_ids.user_id, self.user_portal)
@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="project_collaborator_view_form" model="ir.ui.view">
<field name="name">project.collaborator.view.form</field>
<field name="model">project.collaborator</field>
<field name="arch" type="xml">
<form string="Project Collaborator" create="0" edit="0">
<sheet>
<group>
<field name="project_id" options="{'no_create': True}" />
<field name="partner_id" options="{'no_create': True}" />
</group>
</sheet>
</form>
</field>
</record>
<record id="project_sharing_access_view_tree" model="ir.ui.view">
<field name="name">project.collaborator.view.tree</field>
<field name="model">project.collaborator</field>
<field name="arch" type="xml">
<tree string="Project Collaborators" create="0">
<field name="project_id" options="{'no_create': True}"/>
<field name="partner_id" options="{'no_create': True}"/>
</tree>
</field>
</record>
<record id="project_collaborator_view_search" model="ir.ui.view">
<field name="name">project.collaborator.view.search</field>
<field name="model">project.collaborator</field>
<field name="arch" type="xml">
<search>
<field name="partner_id" />
<field name="project_id" />
<group expand="0" string="Group By">
<filter name="project" string="Project" context="{'group_by': 'project_id'}" />
<filter name="collaborator" string="Collaborator" context="{'group_by': 'partner_id'}" />
</group>
</search>
</field>
</record>
<record id="project_collaborator_action" model="ir.actions.act_window">
<field name="name">Project Collaborators</field>
<field name="res_model">project.collaborator</field>
<field name="view_mode">tree,form</field>
<field name="domain">[('project_id', '=', active_id)]</field>
<field name="search_view_id" ref="project_collaborator_view_search"/>
</record>
</odoo>
@@ -6,14 +6,17 @@
<a t-if="project" t-attf-href="/my/projects?{{ keep_query() }}">Projects</a>
<t t-else="">Projects</t>
</li>
<li t-if="project" class="breadcrumb-item active text-truncate col-8 col-lg-10">
<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>
<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"/>
</li>
<li t-if="page_name == 'task' or task" t-attf-class="breadcrumb-item #{'active ' if not task else ''}">
<li t-if="page_name == 'task' or (task and not project)" t-attf-class="breadcrumb-item #{'active ' if not task else ''}">
<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 col-8 col-lg-10 text-truncate">
<li t-if="task" class="breadcrumb-item active text-truncate">
<span t-field="task.name"/>
</li>
</xpath>
@@ -68,52 +71,65 @@
<t t-call="portal.portal_layout">
<t t-set="o_portal_fullwidth_alert" groups="project.group_project_user">
<t t-call="portal.portal_back_in_edit_mode">
<t t-set="backend_url" t-value="'/web#model=project.project&amp;id=%s&amp;view_type=form' % (project.id)"/>
<t t-set="backend_url" t-value="'/web#model=project.project&amp;id=%s&amp;view_type=kanban' % (project.id)"/>
</t>
</t>
<t t-call="portal.portal_record_layout">
<t t-set="card_header">
<h5 class="mb-0">
<span t-field="project.name" class="text-truncate"/>
<span class="float-right">
<a role="button" t-attf-href="/my/tasks?filterby=#{project.id}" class="btn btn-sm btn-secondary">
<span class="fa fa-tasks" role="img" aria-label="Tasks" title="Tasks"/>
<span t-esc="project.task_count_with_subtasks" />
<span t-field="project.label_tasks" />
</a>
</span>
</h5>
</t>
<t t-set="card_body">
<div class="row">
<div t-if="project.user_id" class="col-12 col-md-6">
<h6><strong> Project Manager </strong></h6>
<div class="row">
<div class="col flex-grow-0 pr-3">
<img class="rounded-circle mt-1 o_portal_contact_img" t-att-src="image_data_uri(project.user_id.avatar_1024)" alt="Contact"/>
</div>
<div class="col pl-sm-0">
<div t-field="project.user_id" t-options='{"widget": "contact", "fields": ["name"]}'/>
<a t-attf-href="mailto:{{project.user_id.email}}" t-if="project.user_id.email"><div t-field="project.user_id" t-options='{"widget": "contact", "fields": ["email"]}'/></a>
<a t-attf-href="tel:{{project.user_id.phone}}" t-if="project.user_id.phone"><div t-field="project.user_id" t-options='{"widget": "contact", "fields": ["phone"]}'/></a>
</div>
</div>
</div>
<div t-if="project.partner_id" class="col-12 col-md-6 mb-2 mb-md-0">
<h6><strong> Customer </strong></h6>
<div class="row">
<div class="col flex-grow-0 pr-3">
<img class="rounded-circle mt-1 o_portal_contact_img" t-att-src="image_data_uri(project.partner_id.avatar_1024)" alt="Contact"/>
</div>
<div class="col pl-sm-0">
<div t-field="project.partner_id" t-options='{"widget": "contact", "fields": ["name"]}'/>
<a t-attf-href="mailto:{{project.partner_email}}" t-if="project.partner_email"><div t-field="project.partner_id" t-options='{"widget": "contact", "fields": ["email"]}'/></a>
<a t-attf-href="tel:{{project.partner_phone}}" t-if="project.partner_phone"><div t-field="project.partner_id" t-options='{"widget": "contact", "fields": ["phone"]}'/></a>
</div>
</div>
</div>
</div>
<t t-call="portal.portal_searchbar">
<t t-set="title">Tasks</t>
</t>
<t t-if="not grouped_tasks">
<div class="alert alert-warning mt8" role="alert">
There are no tasks.
</div>
</t>
<t t-call="project.portal_tasks_list"/>
</t>
</template>
<template id="portal_tasks_list" name="Tasks List">
<t t-if="grouped_tasks">
<t t-call="portal.portal_table">
<t t-foreach="grouped_tasks" t-as="tasks">
<thead>
<tr t-attf-class="{{'thead-light' if not groupby == 'none' else ''}}">
<th class="text-left">Ref</th>
<th t-if="groupby == 'none'">Name</th>
<th t-if="groupby == 'project'">
<em class="font-weight-normal text-muted"><span t-field="tasks[0].sudo().project_id.label_tasks"/> for project:</em>
<span t-field="tasks[0].sudo().project_id.name"/></th>
<th t-if="groupby == 'stage'">
<em class="font-weight-normal text-muted"><span t-field="tasks[0].sudo().project_id.label_tasks"/> in stage:</em>
<span class="text-truncate" t-field="tasks[0].sudo().stage_id.name"/></th>
<th t-if="groupby == 'user'">
<em class="font-weight-normal text-muted" t-if="tasks[0].sudo().user_id"><span t-field="tasks[0].sudo().project_id.label_tasks"/> assigned to:</em>
<span class="text-truncate" t-field="tasks[0].sudo().user_id.name"/></th>
<th t-if="groupby == 'customer'">
<em class="font-weight-normal text-muted" t-if="tasks[0].sudo().partner_id"><span t-field="tasks[0].sudo().project_id.label_tasks"/> for customer:</em>
<span class="text-truncate" t-field="tasks[0].sudo().partner_id.name"/></th>
<th t-if="groupby != 'project'" class="text-center" name="portal_my_tasks_project_th">Project</th>
<th t-if="groupby != 'stage'" class="text-center">Stage</th>
</tr>
</thead>
<tbody>
<t t-foreach="tasks" t-as="task">
<tr>
<td class="text-left">
#<span t-esc="task.id"/>
</td>
<td>
<a t-attf-href="/my/#{task_url}/#{task.id}?{{ keep_query() }}"><span t-field="task.name"/></a>
</td>
<td t-if="groupby != 'project'" class="text-center">
<span class="badge badge-pill badge-info" title="Current project of the task" t-esc="task.project_id.name" />
</td>
<td t-if="groupby != 'stage'" class="text-center">
<span class="badge badge-pill badge-info" title="Current stage of the task" t-esc="task.stage_id.name" />
</td>
</tr>
</t>
</tbody>
</t>
</t>
</t>
@@ -131,50 +147,7 @@
There are no tasks.
</div>
</t>
<t t-if="grouped_tasks">
<t t-call="portal.portal_table">
<t t-foreach="grouped_tasks" t-as="tasks">
<thead>
<tr t-attf-class="{{'thead-light' if not groupby == 'none' else ''}}">
<th class="text-left">Ref</th>
<th t-if="groupby == 'none'">Name</th>
<th t-if="groupby == 'project'">
<em class="font-weight-normal text-muted"><span t-field="tasks[0].sudo().project_id.label_tasks"/> for project:</em>
<span t-field="tasks[0].sudo().project_id.name"/></th>
<th t-if="groupby == 'stage'">
<em class="font-weight-normal text-muted"><span t-field="tasks[0].sudo().project_id.label_tasks"/> in stage:</em>
<span class="text-truncate" t-field="tasks[0].sudo().stage_id.name"/></th>
<th t-if="groupby == 'user'">
<em class="font-weight-normal text-muted" t-if="tasks[0].sudo().user_id"><span t-field="tasks[0].sudo().project_id.label_tasks"/> assigned to:</em>
<span class="text-truncate" t-field="tasks[0].sudo().user_id.name"/></th>
<th t-if="groupby == 'customer'">
<em class="font-weight-normal text-muted" t-if="tasks[0].sudo().partner_id"><span t-field="tasks[0].sudo().project_id.label_tasks"/> for customer:</em>
<span class="text-truncate" t-field="tasks[0].sudo().partner_id.name"/></th>
<th t-if="groupby != 'project'" class="text-center" name="portal_my_tasks_project_th">Project</th>
<th t-if="groupby != 'stage'" class="text-center">Stage</th>
</tr>
</thead>
<tbody>
<t t-foreach="tasks" t-as="task">
<tr>
<td class="text-left">
#<span t-esc="task.id"/>
</td>
<td>
<a t-attf-href="/my/task/#{task.id}?{{ keep_query() }}"><span t-field="task.name"/></a>
</td>
<td t-if="groupby != 'project'" class="text-center">
<span class="badge badge-pill badge-info" title="Current project of the task" t-esc="task.project_id.name" />
</td>
<td t-if="groupby != 'stage'" class="text-center">
<span class="badge badge-pill badge-info" title="Current stage of the task" t-esc="task.stage_id.name" />
</td>
</tr>
</t>
</tbody>
</t>
</t>
</t>
<t t-call="project.portal_tasks_list"/>
</t>
</template>
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="project_sharing_portal" name="Project Sharing View in Portal">
<t t-call="portal.frontend_layout">
<t t-set="no_footer" t-value="true"/>
<t t-call="project.project_sharing"/>
</t>
</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"/>
</template>
<template id="project_sharing_embed" name="Project Sharing View Embed">
<t t-call="web.layout">
<t t-set="head_project_sharing">
<script type="text/javascript">
odoo.__session_info__ = <t t-out="json.dumps(session_info)"/>;
odoo.loadTemplatesPromise = fetch(`/web/webclient/qweb/${odoo.__session_info__.cache_hashes.qweb}?bundle=project.assets_qweb`).then(doc => doc.text());
</script>
<base target="_parent"/>
<t t-call-assets="web.assets_common" t-js="false"/>
<t t-call-assets="project.webclient" t-js="false"/>
<t t-call-assets="web.assets_common" t-css="false"/>
<t t-call-assets="project.webclient" t-css="false"/>
</t>
<t t-set="head" t-value="head_project_sharing + (head or '')"/>
<t t-set="body_classname" t-value="'o_web_client'"/>
</t>
</template>
</odoo>
@@ -0,0 +1,275 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="project_sharing_quick_create_task_form" model="ir.ui.view">
<field name="name">project.task.form.quick_create</field>
<field name="model">project.task</field>
<field name="priority">999</field>
<field name="groups_id" eval="[(4, ref('base.group_portal')), (4, ref('base.group_user'))]"/>
<field name="arch" type="xml">
<form>
<group>
<field name="name" string="Task Title" placeholder="e.g. New Design"/>
</group>
</form>
</field>
</record>
<record id="project_sharing_project_task_view_kanban" model="ir.ui.view">
<field name="name">project.sharing.project.task.view.kanban</field>
<field name="model">project.task</field>
<field name="priority">999</field>
<field name="groups_id" eval="[(4, ref('base.group_portal')), (4, ref('base.group_user'))]"/>
<field name="arch" type="xml">
<kanban
class="o_kanban_small_column o_kanban_project_tasks"
default_group_by="stage_id"
on_create="quick_create"
quick_create_view="project.project_sharing_quick_create_task_form"
archivable="0"
>
<field name="color"/>
<field name="priority"/>
<field name="stage_id" options='{"group_by_tooltip": {"description": "Description"}}'/>
<field name="user_id"/>
<field name="partner_id"/>
<field name="sequence"/>
<field name="is_closed"/>
<field name="partner_is_company"/>
<field name="displayed_image_id"/>
<field name="active"/>
<field name="allow_subtasks"/>
<field name="child_text"/>
<progressbar field="kanban_state" colors='{"done": "success", "blocked": "danger", "normal": "muted"}'/>
<templates>
<t t-name="kanban-box">
<div t-attf-class="{{!selection_mode ? 'oe_kanban_color_' + kanban_getcolor(record.color.raw_value) : ''}} oe_kanban_card oe_kanban_global_click">
<div class="oe_kanban_content">
<div class="o_kanban_record_top">
<div class="o_kanban_record_headings">
<strong class="o_kanban_record_title">
<s t-if="!record.active.raw_value"><field name="name" widget="name_with_subtask_count"/></s>
<t t-else=""><field name="name" widget="name_with_subtask_count"/></t>
</strong>
<span invisible="context.get('default_project_id', False)"><br/><field name="project_id" required="1"/></span>
<br />
<t t-if="record.partner_id.value">
<span t-if="!record.partner_is_company.raw_value">
<field name="commercial_partner_id"/>
</span>
<span t-else="">
<field name="partner_id"/>
</span>
</t>
<t t-else="record.email_from.raw_value"><span><field name="email_from"/></span></t>
</div>
<div class="o_dropdown_kanban dropdown" t-if="!selection_mode">
<a role="button" class="dropdown-toggle o-no-caret btn" data-toggle="dropdown" data-display="static" href="#" aria-label="Dropdown menu" title="Dropdown menu">
<span class="fa fa-ellipsis-v"/>
</a>
<div class="dropdown-menu" role="menu">
<a t-if="widget.editable" role="menuitem" type="set_cover" class="dropdown-item" data-field="displayed_image_id">Set Cover Image</a>
<a t-if="widget.editable" role="menuitem" type="edit" class="dropdown-item">Edit</a>
<div role="separator" class="dropdown-divider"></div>
<ul class="oe_kanban_colorpicker" data-field="color"/>
</div>
</div>
</div>
<div class="o_kanban_record_body">
<field name="tag_ids" widget="many2many_tags" options="{'color_field': 'color'}"/>
<div t-if="record.date_deadline.raw_value" name="date_deadline" attrs="{'invisible': [('is_closed', '=', True)]}">
<field name="date_deadline" widget="remaining_days"/>
</div>
<div t-if="record.displayed_image_id.value">
<field name="displayed_image_id" widget="attachment_image"/>
</div>
</div>
<div class="o_kanban_record_bottom" t-if="!selection_mode">
<div class="oe_kanban_bottom_left">
<field name="priority" widget="priority"/>
</div>
<div class="oe_kanban_bottom_right" t-if="!selection_mode">
<field name="kanban_state" widget="state_selection"/>
<field name="user_id" widget="many2one_avatar_user" options="{'no_open_chat': True}"/>
</div>
</div>
</div>
<div class="oe_clear"></div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<record id="project_sharing_project_task_view_tree" model="ir.ui.view">
<field name="name">project.sharing.project.task.tree</field>
<field name="model">project.task</field>
<field name="priority">999</field>
<field name="arch" type="xml">
<tree string="Tasks" sample="1" delete="0">
<field name="is_closed" invisible="1" />
<field name="allow_subtasks" invisible="1" />
<field name="sequence" invisible="1" readonly="1"/>
<field name="priority" widget="priority" optional="show" nolabel="1"/>
<field name="name" widget="name_with_subtask_count"/>
<field name="child_text" invisible="1"/>
<field name="company_id" invisible="1"/>
<field name="partner_id" optional="hide"/>
<field name="user_id" optional="show" widget="many2one_avatar_user" options="{'no_open_chat': True}"/>
<field name="date_deadline" optional="hide" widget="remaining_days" attrs="{'invisible': [('is_closed', '=', True)]}"/>
<field name="tag_ids" widget="many2many_tags" options="{'color_field': 'color'}" optional="show"/>
<field name="kanban_state" widget="state_selection" optional="hide"/>
<field name="stage_id" invisible="context.get('set_visible',False)" optional="show"/>
</tree>
</field>
</record>
<record id="project_sharing_project_task_view_form" model="ir.ui.view">
<field name="name">project.sharing.project.task.view.form</field>
<field name="model">project.task</field>
<field name="priority">999</field>
<field name="groups_id" eval="[(4, ref('base.group_portal')), (4, ref('base.group_user'))]"/>
<field name="arch" type="xml">
<form string="Project Sharing: Task" class="o_form_project_tasks">
<header>
<field name="stage_id" widget="statusbar" options="{'clickable': '1', 'fold_field': 'fold'}" attrs="{'invisible': [('project_id', '=', False), ('stage_id', '=', False)]}" />
</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)]}"/>
</div>
<widget name="web_ribbon" title="Archived" bg_color="bg-danger" attrs="{'invisible': [('active', '=', True)]}"/>
<div class="oe_title pr-0">
<h1 class="d-flex flex-row justify-content-between">
<field name="priority" widget="priority" class="mr-3"/>
<field name="name" class="o_task_name text-truncate" placeholder="Task Title..."/>
<field name="kanban_state" widget="state_selection" class="ml-auto"/>
</h1>
</div>
<group>
<group>
<field name="project_id" invisible="1"/>
<field name="display_project_id" string="Project" invisible="1"/>
<field name="user_id"
class="o_task_user_field"
options="{'no_open': True, 'no_open_chat': True}"
widget="many2one_avatar_user"
domain="[('share', '=', False)]"/>
</group>
<group>
<field name="active" invisible="1"/>
<field name="parent_id" invisible="1" />
<field name="company_id" invisible="1" />
<field name="is_closed" invisible="1" />
<field name="allow_subtasks" invisible="1" />
<field name="partner_id" options="{'no_open': True, 'no_create': True, 'no_edit': True}"/>
<field name="date_deadline" attrs="{'invisible': [('is_closed', '=', True)]}"/>
<field name="tag_ids" widget="many2many_tags" options="{'color_field': 'color', 'no_create_edit': True, 'no_edit_color': True}"/>
</group>
</group>
<notebook>
<page name="description_page" string="Description">
<field name="description" type="html"/>
</page>
<page name="sub_tasks_page" string="Sub-tasks" attrs="{'invisible': [('allow_subtasks', '=', False)]}">
<field name="child_ids" context="{'default_project_id': project_id if not parent_id or not display_project_id else display_project_id, 'default_user_id': uid, 'default_parent_id': id, 'default_partner_id': partner_id}">
<tree editable="bottom">
<field name="project_id" invisible="1"/>
<field name="is_closed" invisible="1"/>
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="display_project_id" string="Project" optional="hide" invisible="1"/>
<field name="company_id" invisible="1"/>
<field name="partner_id" options="{'no_open': True, 'no_create': True, 'no_edit': True}" optional="hide"/>
<field name="user_id" widget="many2one_avatar_user" optional="show" options="{'no_open_chat': True}"/>
<field name="date_deadline" attrs="{'invisible': [('is_closed', '=', True)]}" optional="show"/>
<field name="tag_ids" widget="many2many_tags" options="{'color_field': 'color'}" optional="hide"/>
<field name="kanban_state" widget="state_selection" optional="hide"/>
<field name="stage_id" optional="show"/>
<button name="action_open_task" type="object" title="View Task" string="View Task" class="btn btn-link pull-right"
context="{'form_view_ref': 'project.project_sharing_project_task_view_form'}"
attrs="{'invisible': [('display_project_id', '!=', False), ('display_project_id', '!=', 'parent.display_project_id')]}"/>
</tree>
</field>
</page>
</notebook>
</sheet>
<div class="oe_chatter"/>
</form>
</field>
</record>
<record id="project_sharing_project_task_view_search" model="ir.ui.view">
<field name="name">project.task.search.form</field>
<field name="model">project.task</field>
<field name="priority">999</field>
<field name="arch" type="xml">
<search string="Tasks">
<field name="name" string="Task"/>
<field name="tag_ids"/>
<field name="user_id"/>
<field name="partner_id" operator="child_of"/>
<field name="stage_id"/>
<field string="Project" name="display_project_id"/>
<filter string="Unassigned" name="unassigned" domain="[('user_id', '=', False)]"/>
<separator/>
<filter string="Starred" name="starred" domain="[('priority', 'in', [1, 2])]"/>
<filter string="Late Tasks" name="late" domain="[('date_deadline', '&lt;', context_today().strftime('%Y-%m-%d'))]"/>
<separator/>
<filter invisible="1" string="Today Activities" name="activities_today"
domain="[('activity_ids.date_deadline', '=', context_today().strftime('%Y-%m-%d'))]"/>
<filter invisible="1" string="Future Activities" name="activities_upcoming_all"
domain="[('activity_ids.date_deadline', '&gt;', context_today().strftime('%Y-%m-%d'))]"/>
<group expand="0" string="Group By">
<filter string="Stage" name="stage" context="{'group_by': 'stage_id'}"/>
<filter string="Assigned to" name="user" context="{'group_by': 'user_id'}"/>
<filter string="Project" name="project" context="{'group_by': 'project_id'}"/>
<filter string="Customer" name="customer" context="{'group_by': 'partner_id'}"/>
<filter string="Kanban State" name="kanban_state" context="{'group_by': 'kanban_state'}"/>
<filter string="Deadline" name="date_deadline" context="{'group_by': 'date_deadline'}"/>
<filter string="Creation Date" name="group_create_date" context="{'group_by': 'create_date'}"/>
</group>
</search>
</field>
</record>
<record id="project_sharing_project_task_action" model="ir.actions.act_window">
<field name="name">Project Sharing</field>
<field name="res_model">project.task</field>
<field name="view_mode">kanban,tree,form</field>
<field name="search_view_id" ref="project.project_sharing_project_task_view_search"/>
<field name="domain">[('display_project_id', '=', active_id)]</field>
<field name="context">{
'default_project_id': active_id,
'delete': 0,
}</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_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"/>
<field name="view_id" ref="project.project_sharing_project_task_view_kanban"/>
</record>
<record id="project_sharing_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"/>
<field name="view_id" ref="project.project_sharing_project_task_view_tree"/>
</record>
<record id="project_sharing_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"/>
<field name="view_id" ref="project.project_sharing_project_task_view_form"/>
</record>
</odoo>
+15 -4
View File
@@ -264,8 +264,10 @@
<field name="arch" type="xml">
<form string="Project" delete="0">
<header>
<button name="%(portal.portal_share_action)d" string="Share" type="action" class="oe_highlight" groups="project.group_project_manager"
attrs="{'invisible': [('privacy_visibility', '!=', 'portal')]}"/>
<button name="%(project.project_share_wizard_action)d" string="Share Readonly" type="action" class="oe_highlight" groups="project.group_project_manager"
attrs="{'invisible': [('privacy_visibility', '!=', 'portal')]}" context="{'default_access_mode': 'read'}"/>
<button name="%(project.project_share_wizard_action)d" string="Share Editable" type="action" class="oe_highlight" groups="project.group_project_manager"
attrs="{'invisible': [('privacy_visibility', '!=', 'portal')]}" context="{'default_access_mode': 'edit'}"/>
<field name="stage_id" widget="statusbar" options="{'clickable': '1', 'fold_field': 'fold'}" groups="project.group_project_stages"/>
</header>
<sheet string="Project">
@@ -312,6 +314,16 @@
<field name="last_update_status" widget="status_with_color" options="{'color_field': 'last_update_color', 'no_quick_edit': 1}"/>
</div>
</button>
<button class="oe_stat_button" name="%(project.project_collaborator_action)d" type="action" icon="fa-users" groups="project.group_project_manager">
<div class="o_field_widget o_stat_info">
<span class="o_stat_value">
<field name="collaborator_count" nolabel="1"/>
</span>
<span class="o_stat_text">
Collaborators
</span>
</div>
</button>
</div>
<widget name="web_ribbon" title="Archived" bg_color="bg-danger" attrs="{'invisible': [('active', '=', True)]}"/>
<div class="oe_title">
@@ -640,7 +652,7 @@
<ul class="oe_kanban_colorpicker" data-field="color" role="popup"/>
</div>
<div role="menuitem" class="col-4">
<a t-if="record.privacy_visibility.raw_value == 'portal'" class="dropdown-item" role="menuitem" name="%(portal.portal_share_action)d" type="action">Share</a>
<a t-if="record.privacy_visibility.raw_value == 'portal'" class="dropdown-item" role="menuitem" name="%(project.project_share_wizard_action)d" type="action">Share</a>
<a class="dropdown-item" role="menuitem" type="edit">Edit</a>
</div>
</div>
@@ -975,7 +987,6 @@
<field name="activity_state"/>
<field name="rating_last_value"/>
<field name="rating_ids"/>
<field name="active"/>
<field name="allow_subtasks"/>
<field name="child_text"/>
<progressbar field="kanban_state" colors='{"done": "success", "blocked": "danger", "normal": "muted"}'/>
+1
View File
@@ -3,3 +3,4 @@
from . import project_delete_wizard
from . import project_task_type_delete
from . import project_share_wizard
@@ -0,0 +1,47 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
class ProjectShareWizard(models.TransientModel):
_name = 'project.share.wizard'
_inherit = 'portal.share'
_description = 'Project Sharing'
@api.model
def default_get(self, fields):
result = super().default_get(fields)
if not result.get('access_mode'):
result.update(
access_mode='read',
display_access_mode=True,
)
return result
@api.model
def _selection_target_model(self):
return [(model.model, model.name) for model in self.env['ir.model'].sudo().search([('model', '=', 'project.project')])]
access_mode = fields.Selection([('read', 'Readonly'), ('edit', 'Edit')])
display_access_mode = fields.Boolean()
@api.depends('res_model', 'res_id')
def _compute_resource_ref(self):
for wizard in self:
if wizard.res_model and wizard.res_model == 'project.project':
wizard.resource_ref = '%s,%s' % (wizard.res_model, wizard.res_id or 0)
else:
wizard.resource_ref = None
def action_send_mail(self):
self.ensure_one()
if self.access_mode == 'edit':
portal_partners = self.partner_ids.filtered('user_ids')
note = self._get_note()
self.resource_ref._add_collaborators(self.partner_ids)
self._send_public_link(note, portal_partners)
self._send_signup_link(note, partners=self.partner_ids - portal_partners)
self.resource_ref.message_subscribe(partner_ids=self.partner_ids.ids)
return {'type': 'ir.actions.act_window_close'}
return super().action_send_mail()
@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="project_share_wizard_view_form" model="ir.ui.view">
<field name="name">project.share.wizard.view.form</field>
<field name="model">project.share.wizard</field>
<field name="arch" type="xml">
<form string="Share Project">
<field name="res_model" invisible="1"/>
<field name="res_id" invisible="1"/>
<field name="display_access_mode" invisible="1" />
<p class="alert alert-warning" attrs="{'invisible': [('access_warning', '=', '')]}" role="alert"><field name="access_warning"/></p>
<group attrs="{'invisible': [('display_access_mode', '=', False)]}">
<field class="flex-row" name="access_mode" widget="radio"/>
</group>
<group name="share_link" attrs="{'invisible': [('access_mode', '=', 'edit')]}">
<field name="share_link" widget="CopyClipboardChar" options="{'string': 'Copy Link'}"/>
</group>
<group>
<div class="o_td_label">
<label for="partner_ids" string="Invite People" attrs="{'invisible': [('access_mode', '=', 'read')]}"/>
<label for="partner_ids" attrs="{'invisible': [('access_mode', '=', 'edit')]}"/>
</div>
<field name="partner_ids" widget="many2many_tags_email" placeholder="Add contacts to share the project..." nolabel="1"/>
</group>
<group>
<field name="note" placeholder="Add a note"/>
</group>
<footer>
<button string="Send" name="action_send_mail" attrs="{'invisible': [('access_warning', '!=', '')]}" type="object" class="btn-primary" data-hotkey="q"/>
<button string="Cancel" class="btn-default" special="cancel" data-hotkey="z" />
</footer>
</form>
</field>
</record>
<record id="project_share_wizard_action" model="ir.actions.act_window">
<field name="name">Share Project</field>
<field name="res_model">project.share.wizard</field>
<field name="binding_model_id" ref="model_project_project"/>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>
+1
View File
@@ -17,6 +17,7 @@ This module allows to generate a project/task from sales orders.
'views/product_views.xml',
'views/project_task_views.xml',
'views/sale_order_views.xml',
'views/project_sharing_views.xml',
],
'auto_install': True,
'license': 'LGPL-3',
@@ -25,13 +25,6 @@ class SaleProjectCustomerPortal(ProjectCustomerPortal):
groupby_mapping.update(sale_order='sale_order_id', sale_line='sale_line_id')
return groupby_mapping
def _task_get_grouped_tasks(self, groupby, tasks):
if groupby:
grouped_tasks = [request.env['project.task'].concat(*g) for k, g in groupbyelem(tasks, itemgetter(groupby))]
else:
grouped_tasks = super()._task_get_grouped_tasks(groupby, tasks)
return grouped_tasks
def _task_get_searchbar_inputs(self):
values = super()._task_get_searchbar_inputs()
values['sale_order'] = {'input': 'sale_order', 'label': _('Search in Sales Order'), 'order': 4}
+4
View File
@@ -67,6 +67,10 @@ class ProjectTask(models.Model):
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')
@property
def SELF_READABLE_FIELDS(self):
return super().SELF_READABLE_FIELDS | {'sale_order_id', 'sale_line_id'}
@api.depends('sale_line_id', 'project_id', 'commercial_partner_id')
def _compute_sale_order_id(self):
for task in self:
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="project_sharing_inherit_project_task_view_form" model="ir.ui.view">
<field name="name">project.task.view.inherit</field>
<field name="model">project.task</field>
<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"/>
<button class="d-none d-md-inline oe_stat_button o_debounce_disabled"
type="object" name="action_view_so" icon="fa-dollar" special="cancel"
attrs="{'invisible': [('sale_order_id', '=', False)]}"
string="Sales Order"/>
</div>
<xpath expr="//field[@name='partner_id']" position="attributes">
<attribute name="options">{'no_open': True, 'no_create': True, 'no_edit': True}</attribute>
<attribute name="context">{'res_partner_search_mode': 'customer'}</attribute>
</xpath>
<xpath expr="//field[@name='partner_id']" position="after">
<field name="commercial_partner_id" invisible="1" />
<field name="sale_line_id" string="Sales Order Item" attrs="{'invisible': [('partner_id', '=', False)]}" options='{"no_open": True}' readonly="1" context="{'create': False, 'edit': False, 'delete': False}"/>
</xpath>
</field>
</record>
<record id="project_sharing_inherit_project_task_view_search" model="ir.ui.view">
<field name="name">project.task.search.inherit</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="//field[@name='partner_id']" position="before">
<field name="sale_order_id" string="Sale Order" filter_domain="['|', ('sale_order_id', 'ilike', self), ('sale_line_id', 'ilike', self)]"/>
</xpath>
<xpath expr="//group/filter[@name='customer']" position="after">
<filter name="sale_order_id" string="Sales Order Id" context="{'group_by': 'sale_order_id'}" />
</xpath>
</field>
</record>
</odoo>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="portal_my_tasks_inherit" inherit_id="project.portal_my_tasks">
<template id="portal_tasks_list_inherit" inherit_id="project.portal_tasks_list">
<xpath expr="//t[@t-call='portal.portal_table']//thead/tr/th[@name='portal_my_tasks_project_th']" position="before">
<th t-if="groupby == 'sale_order'">
<em class="font-weight-normal text-muted" t-if="tasks[0].sudo().sale_order_id"><span t-field="tasks[0].sudo().project_id.label_tasks"/> for sale order:</em>
+1
View File
@@ -27,6 +27,7 @@ have real delivered quantities in sales orders.
'views/res_config_settings_views.xml',
'views/hr_timesheet_templates.xml',
'views/sale_timesheet_portal_templates.xml',
'views/project_sharing_views.xml',
'report/project_profitability_report_analysis_views.xml',
'data/sale_timesheet_filters.xml',
'wizard/project_create_sale_order_views.xml',
+12 -2
View File
@@ -340,8 +340,10 @@ class ProjectTask(models.Model):
def _get_default_partner_id(self, project, parent):
res = super()._get_default_partner_id(project, parent)
if not res and project:
if project.pricing_type == 'employee_rate':
return project.sale_line_employee_ids.sale_line_id.order_partner_id[:1]
# project in sudo if the current user is a portal user.
related_project = project if not self.user_has_groups('!base.group_user,base.group_portal') else project.sudo()
if related_project.pricing_type == 'employee_rate':
return related_project.sale_line_employee_ids.sale_line_id.order_partner_id[:1]
return res
sale_order_id = fields.Many2one(domain="['|', '|', ('partner_id', '=', partner_id), ('partner_id', 'child_of', commercial_partner_id), ('partner_id', 'parent_of', partner_id)]")
@@ -354,6 +356,14 @@ class ProjectTask(models.Model):
remaining_hours_so = fields.Float('Remaining Hours on SO', compute='_compute_remaining_hours_so', compute_sudo=True)
remaining_hours_available = fields.Boolean(related="sale_line_id.remaining_hours_available")
@property
def SELF_READABLE_FIELDS(self):
return super().SELF_READABLE_FIELDS | {
'allow_billable',
'remaining_hours_available',
'remaining_hours_so',
}
@api.depends('sale_line_id', 'timesheet_ids', 'timesheet_ids.unit_amount')
def _compute_remaining_hours_so(self):
# TODO This is not yet perfectly working as timesheet.so_line stick to its old value although changed
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="project_sharing_inherit_project_task_view_form" model="ir.ui.view">
<field name="name">project.task.form.inherit.timesheet</field>
<field name="model">project.task</field>
<field name="inherit_id" ref="project.project_sharing_project_task_view_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='timesheet_ids']/tree" position="attributes">
<attribute name="decoration-muted">timesheet_invoice_id != False</attribute>
</xpath>
<xpath expr="//field[@name='timesheet_ids']/tree/field[@name='unit_amount']" position="before">
<field name="timesheet_invoice_id" invisible="1"/>
<field name="so_line"
attrs="{'column_invisible': [('parent.allow_billable', '=', False)]}"
context="{'with_remaining_hours': True, 'with_price_unit': True}" options="{'no_create': True, 'no_open': True}"
optional="hide"/>
</xpath>
<xpath expr="//field[@name='remaining_hours']" position="after">
<field name="allow_billable" invisible="1" />
<field name="remaining_hours_available" 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)]}"/>
<label class="font-weight-bold" for="remaining_hours_so" string="Remaining Days on SO"
attrs="{'invisible': ['|', ('encode_uom_in_days', '=', False), ('remaining_hours_so', '&lt;', 0)]}"/>
<label class="font-weight-bold text-danger" for="remaining_hours_so" string="Remaining Hours on SO"
attrs="{'invisible': ['|', ('encode_uom_in_days', '=', True), ('remaining_hours_so', '&gt;=', 0)]}"/>
<label class="font-weight-bold text-danger" for="remaining_hours_so" string="Remaining Days on SO"
attrs="{'invisible': ['|', ('encode_uom_in_days', '=', False), ('remaining_hours_so', '&gt;=', 0)]}"/>
</span>
<field name="remaining_hours_so" nolabel="1" widget="timesheet_uom" attrs="{'invisible': ['|', '|', '|', '|', ('allow_billable', '=', False), ('sale_order_id', '=', False), ('partner_id', '=', False), ('sale_line_id', '=', False), ('remaining_hours_available', '=', False)]}"></field>
</xpath>
</field>
</record>
</odoo>
@@ -1486,8 +1486,8 @@ var FieldX2Many = AbstractField.extend(WidgetAdapterMixin, {
addCreateLine: this._hasCreateLine(),
addTrashIcon: this._hasTrashIcon(),
isMany2Many: this.isMany2Many,
no_open: (this.isReadonly && !this.hasReadonlyModifier) &&
(this._canQuickEdit || toBoolElse(arch.attrs.no_open || '', false)),
no_open: ((this.isReadonly && !this.hasReadonlyModifier) &&
this._canQuickEdit) || toBoolElse(arch.attrs.no_open || '', false),
columnInvisibleFields: this.currentColInvisibleFields,
});
}