[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:
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 >= 100" decoration-warning="progress >= 80 and progress < 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 > 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 > 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 > 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 >= 80 and record.progress.raw_value <= 100"/>
|
||||
<t t-set="badge" t-value="'badge-danger'" t-if="record.remaining_hours.raw_value < 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 >= 100" decoration-warning="progress >= 80 and progress < 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', '>', 0)]"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -2,3 +2,4 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import portal
|
||||
from . import project_sharing_chatter
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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})
|
||||
@@ -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
|
||||
|
||||
|
@@ -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>
|
||||
@@ -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),
|
||||
'|',
|
||||
'&',
|
||||
('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]),
|
||||
'&',
|
||||
('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 && 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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&id=%s&view_type=form' % (project.id)"/>
|
||||
<t t-set="backend_url" t-value="'/web#model=project.project&id=%s&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', '<', 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', '>', 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>
|
||||
@@ -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"}'/>
|
||||
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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', '<', 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', '<', 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', '>=', 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', '>=', 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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user