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 + + +