From 5ea5ddafea936ac53b2dd7b098c1da644c5bc577 Mon Sep 17 00:00:00 2001 From: "Xavier BOL (xbo)" Date: Tue, 3 Aug 2021 18:04:53 +0200 Subject: [PATCH] [IMP] project: select access rights in project sharing Before this commit, when the portal user can access to project sharing views, he can just read. If we just add ir.rule to allow the edition, the portal could create/edit. We have to allow the project manager to define the access rights of portal users when he want to share them a project with the project sharing feature. This commit adds 3 access modes portal users in project sharing views. 1. **Readonly**: the portal can only see the tasks in the different views. 2. **Comment**: the portal can use the chatter in task form view. 3. **Edit**: the portal user can create and edit tasks. task-2379518 closes #73341 Co-Authored-by: Yannick Tivisse (yti) --- addons/project/__manifest__.py | 1 + addons/project/controllers/portal.py | 4 +- addons/project/models/__init__.py | 1 + addons/project/models/project.py | 59 ++++++++++++-- .../project/models/project_sharing_access.py | 24 ++++++ addons/project/security/ir.model.access.csv | 4 + addons/project/security/project_security.xml | 38 +++++++-- .../src/project_sharing/project_sharing.js | 4 +- .../project/views/project_sharing_views.xml | 7 +- addons/project/views/project_views.xml | 4 +- addons/project/wizard/__init__.py | 1 + addons/project/wizard/project_share_wizard.py | 78 +++++++++++++++++++ .../wizard/project_share_wizard_views.xml | 47 +++++++++++ 13 files changed, 252 insertions(+), 20 deletions(-) create mode 100644 addons/project/models/project_sharing_access.py create mode 100644 addons/project/wizard/project_share_wizard.py create mode 100644 addons/project/wizard/project_share_wizard_views.xml diff --git a/addons/project/__manifest__.py b/addons/project/__manifest__.py index 869f59caee8..bde65068b5d 100644 --- a/addons/project/__manifest__.py +++ b/addons/project/__manifest__.py @@ -32,6 +32,7 @@ 'views/project_update_views.xml', 'views/project_update_templates.xml', 'views/project_project_stage_views.xml', + 'wizard/project_share_wizard_views.xml', 'views/project_views.xml', 'views/res_partner_views.xml', 'views/res_config_settings_views.xml', diff --git a/addons/project/controllers/portal.py b/addons/project/controllers/portal.py index 7fdebe216a3..e7a3f3dfd53 100644 --- a/addons/project/controllers/portal.py +++ b/addons/project/controllers/portal.py @@ -104,8 +104,7 @@ class ProjectCustomerPortal(CustomerPortal): @http.route("/embed/project/", 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(): + 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() @@ -124,6 +123,7 @@ class ProjectCustomerPortal(CustomerPortal): cache_hashes=cache_hashes, action_name='project.project_sharing_project_task_action', project_id=project.id, + can_edit=project.user_can_edit, user_companies={ 'current_company': project_company.id, 'allowed_companies': { diff --git a/addons/project/models/__init__.py b/addons/project/models/__init__.py index 2fe4f0e7823..dae83cffa9e 100644 --- a/addons/project/models/__init__.py +++ b/addons/project/models/__init__.py @@ -6,6 +6,7 @@ from . import project_milestone from . import project_project_stage from . import project_task_recurrence from . import project +from . import project_sharing_access from . import project_update from . import res_config_settings from . import res_partner diff --git a/addons/project/models/project.py b/addons/project/models/project.py index 6701e7a1860..781c0629466 100644 --- a/addons/project/models/project.py +++ b/addons/project/models/project.py @@ -9,10 +9,11 @@ from random import randint from odoo import api, fields, models, tools, SUPERUSER_ID, _ from odoo.exceptions import UserError, ValidationError, AccessError -from odoo.osv.expression import OR +from odoo.osv import expression from .project_task_recurrence import DAYS, WEEKS from .project_update import STATUS_COLOR +from .project_sharing_access import PROJECT_SHARING_ACCESS_MODE PROJECT_TASK_READABLE_FIELDS = { @@ -37,6 +38,7 @@ PROJECT_TASK_READABLE_FIELDS = { 'displayed_image_id', 'display_name', 'priority', + 'project_sharing_access_mode', } PROJECT_TASK_WRITABLE_FIELDS = { @@ -280,6 +282,9 @@ 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 + project_sharing_access_ids = fields.One2many('project.sharing.access', 'project_id', 'Project Sharing Access', copy=False) + # 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')) @@ -695,6 +700,25 @@ class Project(models.Model): }) project.write({'analytic_account_id': analytic_account.id}) + def _check_project_sharing_access(self, access_mode='read'): + self.ensure_one() + if access_mode not in map(lambda access: access[0], PROJECT_SHARING_ACCESS_MODE): + raise ValidationError('The %s does not exists in project sharing accesses.' % access_mode) + if self.privacy_visibility != 'portal' or self.env.user._is_public(): + return False + if self.env.user.has_group('base.group_portal'): + # check in project sharing access model of the project + project_sharing_access = self.project_sharing_access_ids.filtered(lambda access: access.user_id == self.env.user) + if not project_sharing_access: + return False + if access_mode == 'edit': + return project_sharing_access.access_mode == 'edit' + elif access_mode == 'comment': + return project_sharing_access.access_mode in ('comment', 'edit') + else: + return True + return self.env.user.has_group('base.group_user') + # --------------------------------------------------- # Rating business # --------------------------------------------------- @@ -856,6 +880,9 @@ class Task(models.Model): allow_task_dependencies = fields.Boolean(related='project_id.allow_task_dependencies') depend_on_ids = fields.Many2many('project.task', relation="task_dependencies_rel", column1="task_id", column2="depends_on_id", string="Blocked By", domain="[('allow_task_dependencies', '=', True), ('id', '!=', id)]") + # Project Sharing fields + project_sharing_access_mode = fields.Selection(PROJECT_SHARING_ACCESS_MODE, compute='_compute_project_sharing_access_mode') + # recurrence fields allow_recurring_tasks = fields.Boolean(related='project_id.allow_recurring_tasks') recurring_task = fields.Boolean(string="Recurrent") @@ -1162,6 +1189,26 @@ class Task(models.Model): else: task.stage_id = False + @api.depends_context('uid') + @api.depends('project_id.project_sharing_access_ids', 'project_id.privacy_visibility') + def _compute_project_sharing_access_mode(self): + if self.env.user._is_public(): + self.project_sharing_access_mode = False + return + portal_user = self.user_has_groups('base.group_portal') + project_sharings = self.project_id.filtered(lambda project: project.privacy_visibility == 'portal') + if portal_user: + project_sharing_access_read = self.env['project.sharing.access'].search_read( + [('user_id', '=', self.env.user.id), ('project_id', 'in', project_sharings.ids)], + ['project_id', 'access_mode'] + ) + project_sharing_access_dict = {res['project_id'][0]: res['access_mode'] for res in project_sharing_access_read} + for task in self: + if not portal_user: + task.project_sharing_access_mode = task.project_id in project_sharings and 'edit' + continue + task.project_sharing_access_mode = project_sharing_access_dict.get(task.project_id.id, False) + @api.constrains('recurring_task') def _check_recurring_task(self): if self.filtered(lambda task: task.parent_id and task.recurring_task): @@ -1241,10 +1288,10 @@ class Task(models.Model): 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 + 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 @@ -1412,7 +1459,7 @@ class Task(models.Model): recurrence_domain = [] if recurrence_update == 'subsequent': for task in self: - recurrence_domain = OR([recurrence_domain, ['&', ('recurrence_id', '=', task.recurrence_id.id), ('create_date', '>=', task.create_date)]]) + recurrence_domain = expression.OR([recurrence_domain, ['&', ('recurrence_id', '=', task.recurrence_id.id), ('create_date', '>=', task.create_date)]]) else: recurrence_domain = [('recurrence_id', 'in', self.recurrence_id.ids)] tasks |= self.env['project.task'].search(recurrence_domain) diff --git a/addons/project/models/project_sharing_access.py b/addons/project/models/project_sharing_access.py new file mode 100644 index 00000000000..c164f89681e --- /dev/null +++ b/addons/project/models/project_sharing_access.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + + +PROJECT_SHARING_ACCESS_MODE = [ + ('read', 'Read'), + ('comment', 'Comment'), + ('edit', 'Edit'), +] + + +class ProjectSharingAccess(models.Model): + _name = 'project.sharing.access' + _description = 'Project Sharing Access' + + project_id = fields.Many2one('project.project', 'Project Shared', required=True) + user_id = fields.Many2one('res.users', string='Portal Users', domain=lambda self: [('groups_id', 'in', self.env.ref('base.group_portal').id)], required=True) + access_mode = fields.Selection(PROJECT_SHARING_ACCESS_MODE, string='Access Mode', required=True) + + _sql_constraints = [ + ('unique_user', 'UNIQUE(project_id, user_id)', 'A user cannot be selected more than once in the project sharing access. Please remove duplicate(s) and try again.'), + ] diff --git a/addons/project/security/ir.model.access.csv b/addons/project/security/ir.model.access.csv index 7ad3b2bda89..0f06777ee58 100644 --- a/addons/project/security/ir.model.access.csv +++ b/addons/project/security/ir.model.access.csv @@ -36,3 +36,7 @@ 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_share_project_manager,project.share.wizard.project.manager,model_project_share_wizard,project.group_project_manager,1,1,1,0 +access_project_share_wizard_line_project_manager,project.share.wizard.line.project.manager,model_project_share_wizard_line,project.group_project_manager,1,1,1,0 +access_project_sharing_access_project_manager,project.sharing.access.project.manager,model_project_sharing_access,project.group_project_manager,1,1,1,1 +access_project_sharing_access_project_portal,project.sharing.access.project.portal,model_project_sharing_access,base.group_portal,1,0,0,0 diff --git a/addons/project/security/project_security.xml b/addons/project/security/project_security.xml index 1f56577fdae..83e748dacd6 100644 --- a/addons/project/security/project_security.xml +++ b/addons/project/security/project_security.xml @@ -123,14 +123,33 @@ Project/Task: portal users: (portal and following project) or (portal and following task) [ + ('project_id.privacy_visibility', '=', 'portal'), '|', - '&', - ('project_id.privacy_visibility', '=', 'portal'), - ('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.message_partner_ids', 'child_of', [user.partner_id.commercial_partner_id.id]), + ('message_partner_ids', 'child_of', [user.partner_id.commercial_partner_id.id]), ] + + + + + + + + + Project/Task: portal users: portal user can edit with project sharing feature + + [ + ('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]), + ('project_id.project_sharing_access_ids.user_id', 'in', [user.id]), + ('project_id.project_sharing_access_ids.access_mode', '=', 'edit'), + ] + + + + @@ -162,5 +181,12 @@ + + Project Sharing Access: Portal user can read its own access + + [('user_id', '=', user.id)] + + + diff --git a/addons/project/static/src/project_sharing/project_sharing.js b/addons/project/static/src/project_sharing/project_sharing.js index cf1affa05eb..cc59095bdfe 100644 --- a/addons/project/static/src/project_sharing/project_sharing.js +++ b/addons/project/static/src/project_sharing/project_sharing.js @@ -56,13 +56,15 @@ export class ProjectSharingWebClient extends Component { } async _showView() { - const { action_name, project_id } = session; + const { action_name, project_id, can_edit } = session; await this.actionService.doAction( action_name, { clearBreadcrumbs: true, additionalContext: { active_id: project_id, + create: can_edit, + edit: can_edit, } } ); diff --git a/addons/project/views/project_sharing_views.xml b/addons/project/views/project_sharing_views.xml index db325793e41..ef0fb958115 100644 --- a/addons/project/views/project_sharing_views.xml +++ b/addons/project/views/project_sharing_views.xml @@ -132,7 +132,8 @@
- + +
@@ -143,7 +144,7 @@

- +

@@ -194,7 +195,7 @@
-
+
diff --git a/addons/project/views/project_views.xml b/addons/project/views/project_views.xml index 9427a085848..47661621d4e 100644 --- a/addons/project/views/project_views.xml +++ b/addons/project/views/project_views.xml @@ -264,7 +264,7 @@
-
@@ -640,7 +640,7 @@
diff --git a/addons/project/wizard/__init__.py b/addons/project/wizard/__init__.py index 063a6b88728..96e583bead0 100644 --- a/addons/project/wizard/__init__.py +++ b/addons/project/wizard/__init__.py @@ -3,3 +3,4 @@ from . import project_delete_wizard from . import project_task_type_delete +from . import project_share_wizard diff --git a/addons/project/wizard/project_share_wizard.py b/addons/project/wizard/project_share_wizard.py new file mode 100644 index 00000000000..0b54ae1c307 --- /dev/null +++ b/addons/project/wizard/project_share_wizard.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, Command, fields, models, _ +from odoo.exceptions import ValidationError, UserError + +from ..models.project_sharing_access import PROJECT_SHARING_ACCESS_MODE + + +class ProjectShareWizard(models.TransientModel): + _name = 'project.share.wizard' + _inherit = 'portal.share' + _description = 'Project Share' + + @api.model + def default_get(self, fields_list): + result = super().default_get(fields_list) + if result['res_model'] and result['res_id']: + project = self.env[result['res_model']].browse(result['res_id']) + result.update( + line_ids=[ + Command.create( + {'user_id': access.user_id, 'access_mode': access.access_mode} + ) for access in project.project_sharing_access_ids + ], + ) + return result + + partner_ids = fields.Many2many(compute="_compute_partner_ids", readonly=False, store=True, required=False) + line_ids = fields.One2many('project.share.wizard.line', 'project_share_id') + + @api.depends('line_ids.user_id') + def _compute_partner_ids(self): + for project_share_wizard in self: + project_share_wizard.partner_ids |= project_share_wizard.line_ids.user_id.partner_id + + def action_send_mail_and_confirm_access(self): + if not self.partner_ids: + raise UserError(_('Impossible to send email, no recipients are mentionned.')) + self.action_confirm_access() + return self.action_send_mail() + + def action_confirm_access(self): + project = self.env[self.res_model].browse(self.res_id) + if not project or not project.exists(): + raise ValidationError(_('Impossible to confirm the project sharing access, the project is not found or project sharing is disabled for this project.')) + project_sharing_access_dict = {access.user_id.id: access for access in project.project_sharing_access_ids} + project_sharing_access_vals_list = [ + Command.delete(access.id) + for user_id, access in project_sharing_access_dict.items() + if user_id not in self.line_ids.user_id.ids + ] + for share_access in self.line_ids: + project_sharing_access = project_sharing_access_dict.get(share_access.user_id.id) + if not project_sharing_access: + project_sharing_access_vals_list.append( + Command.create( + {'user_id': share_access.user_id.id, 'access_mode': share_access.access_mode} + ) + ) + elif project_sharing_access.access_mode != share_access.access_mode: + project_sharing_access_vals_list.append(Command.update(project_sharing_access.id, {'access_mode': share_access.access_mode})) + project.write({'project_sharing_access_ids': project_sharing_access_vals_list}) + project.message_subscribe(self.line_ids.user_id.partner_id.ids) + return {'type': 'ir.actions.act_window_close'} + + +class ProjectShareAccessWizard(models.TransientModel): + _name = 'project.share.wizard.line' + _description = 'Project Share Access' + + project_share_id = fields.Many2one('project.share.wizard', required=True, ondelete='cascade') + user_id = fields.Many2one('res.users', string='Portal Users', domain=lambda self: [('groups_id', 'in', self.env.ref('base.group_portal').id)], required=True) + access_mode = fields.Selection(PROJECT_SHARING_ACCESS_MODE, string='Access Mode', required=True) + + _sql_constraints = [ + ('uniqueness_user', 'UNIQUE(project_share_id, user_id)', 'An user cannot be selected more than once in the project sharing access. Please remove duplicate(s) and try again.'), + ] diff --git a/addons/project/wizard/project_share_wizard_views.xml b/addons/project/wizard/project_share_wizard_views.xml new file mode 100644 index 00000000000..b3a6882ab94 --- /dev/null +++ b/addons/project/wizard/project_share_wizard_views.xml @@ -0,0 +1,47 @@ + + + + + project.share.wizard + project.share.wizard + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+
+ + + Share Project + project.share.wizard + + form + new + + +