diff --git a/addons/hr_timesheet/__init__.py b/addons/hr_timesheet/__init__.py index ea15e1da791..5556aba9fe4 100644 --- a/addons/hr_timesheet/__init__.py +++ b/addons/hr_timesheet/__init__.py @@ -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) diff --git a/addons/hr_timesheet/__manifest__.py b/addons/hr_timesheet/__manifest__.py index 92d085b7f1f..4cb43356441 100644 --- a/addons/hr_timesheet/__manifest__.py +++ b/addons/hr_timesheet/__manifest__.py @@ -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', diff --git a/addons/hr_timesheet/models/__init__.py b/addons/hr_timesheet/models/__init__.py index 969632575ce..87061d8bb36 100644 --- a/addons/hr_timesheet/models/__init__.py +++ b/addons/hr_timesheet/models/__init__.py @@ -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 diff --git a/addons/hr_timesheet/models/project.py b/addons/hr_timesheet/models/project.py index 2021e89c08f..3f0e5b51e8b 100644 --- a/addons/hr_timesheet/models/project.py +++ b/addons/hr_timesheet/models/project.py @@ -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) diff --git a/addons/hr_timesheet/models/project_collaborator.py b/addons/hr_timesheet/models/project_collaborator.py new file mode 100644 index 00000000000..d2083a3258e --- /dev/null +++ b/addons/hr_timesheet/models/project_collaborator.py @@ -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}) diff --git a/addons/hr_timesheet/security/hr_timesheet_security.xml b/addons/hr_timesheet/security/hr_timesheet_security.xml index d4fd4902c46..2c35c99bfb2 100644 --- a/addons/hr_timesheet/security/hr_timesheet_security.xml +++ b/addons/hr_timesheet/security/hr_timesheet_security.xml @@ -30,6 +30,25 @@ + + account.analytic.line.timesheet.portal.user + + 0 + [ + ('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]), + ] + + + + + + + account.analytic.line.timesheet.user diff --git a/addons/hr_timesheet/security/ir.model.access.xml b/addons/hr_timesheet/security/ir.model.access.xml new file mode 100644 index 00000000000..5732923928f --- /dev/null +++ b/addons/hr_timesheet/security/ir.model.access.xml @@ -0,0 +1,19 @@ + + + + + + + analytic.account.analytic.line.timesheet.portal.user + + + 0 + 1 + 0 + 0 + 0 + + + + + diff --git a/addons/hr_timesheet/views/project_sharing_views.xml b/addons/hr_timesheet/views/project_sharing_views.xml new file mode 100644 index 00000000000..efbb9bad9ac --- /dev/null +++ b/addons/hr_timesheet/views/project_sharing_views.xml @@ -0,0 +1,174 @@ + + + + + project.sharing.project.task.view.form.inherit + project.task + + + + + + + + + + + + + + + + +
+
+ +
+
+ +
+ + + +
+ + + + + + + + + + + + + + + + + + + +
+
+
+ +
+
+ +
+
+
+
+ +
+
+ + + +
+
+
+
+
+
+
+ + + + + + + + + + + + + + + +
+
+
+
+ + + project.sharing.project.task.timesheet.kanban.inherited + project.task + + + + + + + + + +
+ + + + + + +
+ +
+
+
+
+
+ + + project.task.tree.inherited + project.task + + + + + + + + + + + + + + + + + project.sharing.inherit.project.task.view.search + project.task + + + + + + + + +
diff --git a/addons/mail/static/src/js/m2x_avatar_user.js b/addons/mail/static/src/js/m2x_avatar_user.js index f0a6f12731e..d30d5a95302 100644 --- a/addons/mail/static/src/js/m2x_avatar_user.js +++ b/addons/mail/static/src/js/m2x_avatar_user.js @@ -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({ diff --git a/addons/mail/static/tests/m2x_avatar_user_tests.js b/addons/mail/static/tests/m2x_avatar_user_tests.js index 7e4f4350046..98e3efa1df3 100644 --- a/addons/mail/static/tests/m2x_avatar_user_tests.js +++ b/addons/mail/static/tests/m2x_avatar_user_tests.js @@ -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: ``, + 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: ` + + + +
+ +
+
+
+
`, + }); + + 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: `
`, + 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(); + }); + }); diff --git a/addons/portal/static/src/js/portal_chatter.js b/addons/portal/static/src/js/portal_chatter.js index 962699bc003..0f379460d06 100644 --- a/addons/portal/static/src/js/portal_chatter.js +++ b/addons/portal/static/src/js/portal_chatter.js @@ -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} 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')); } }, diff --git a/addons/portal/static/src/js/portal_composer.js b/addons/portal/static/src/js/portal_composer.js index 8d8cabecb28..645210c0116 100644 --- a/addons/portal/static/src/js/portal_composer.js +++ b/addons/portal/static/src/js/portal_composer.js @@ -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); diff --git a/addons/portal/wizard/portal_share.py b/addons/portal/wizard/portal_share.py index c8c36418a01..48f19e8ff22 100644 --- a/addons/portal/wizard/portal_share.py +++ b/addons/portal/wizard/portal_share.py @@ -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'} diff --git a/addons/project/__init__.py b/addons/project/__init__.py index e35155177e6..0f9917d22dd 100644 --- a/addons/project/__init__.py +++ b/addons/project/__init__.py @@ -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) diff --git a/addons/project/__manifest__.py b/addons/project/__manifest__.py index 625ee834690..d635e16c866 100644 --- a/addons/project/__manifest__.py +++ b/addons/project/__manifest__.py @@ -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', } diff --git a/addons/project/controllers/__init__.py b/addons/project/controllers/__init__.py index 903b755e71e..22decb94323 100644 --- a/addons/project/controllers/__init__.py +++ b/addons/project/controllers/__init__.py @@ -2,3 +2,4 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from . import portal +from . import project_sharing_chatter diff --git a/addons/project/controllers/portal.py b/addons/project/controllers/portal.py index f5bb7cc91c9..a1a4894a54e 100644 --- a/addons/project/controllers/portal.py +++ b/addons/project/controllers/portal.py @@ -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/'], 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/'], 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//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//task/', 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, diff --git a/addons/project/controllers/project_sharing_chatter.py b/addons/project/controllers/project_sharing_chatter.py new file mode 100644 index 00000000000..c3e3bc2b9c2 --- /dev/null +++ b/addons/project/controllers/project_sharing_chatter.py @@ -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) diff --git a/addons/project/models/__init__.py b/addons/project/models/__init__.py index 2fe4f0e7823..2f66f8d42bc 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_collaborator 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 5dedfe41179..8f4a3b7c2d6 100644 --- a/addons/project/models/project.py +++ b/addons/project/models/project.py @@ -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'): diff --git a/addons/project/models/project_collaborator.py b/addons/project/models/project_collaborator.py new file mode 100644 index 00000000000..223d33155ac --- /dev/null +++ b/addons/project/models/project_collaborator.py @@ -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}) diff --git a/addons/project/models/res_partner.py b/addons/project/models/res_partner.py index d7646c8d411..290e92d8474 100644 --- a/addons/project/models/res_partner.py +++ b/addons/project/models/res_partner.py @@ -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 diff --git a/addons/project/security/ir.model.access.csv b/addons/project/security/ir.model.access.csv index ae03a42a613..35c80887da6 100644 --- a/addons/project/security/ir.model.access.csv +++ b/addons/project/security/ir.model.access.csv @@ -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 diff --git a/addons/project/security/ir.model.access.xml b/addons/project/security/ir.model.access.xml new file mode 100644 index 00000000000..5657be31cdd --- /dev/null +++ b/addons/project/security/ir.model.access.xml @@ -0,0 +1,19 @@ + + + + + + + project_sharing_task_portal + + + 0 + 0 + 1 + 1 + 0 + + + + + diff --git a/addons/project/security/project_security.xml b/addons/project/security/project_security.xml index 1f56577fdae..84a5865ae16 100644 --- a/addons/project/security/project_security.xml +++ b/addons/project/security/project_security.xml @@ -119,19 +119,50 @@
+ + Project/Collaborator: portal users: can only see his own collobaroration in shared projects + + [ + ('project_id.privacy_visibility', '=', 'portal'), + ('partner_id', '=', user.partner_id.id), + ] + + + Project/Task: portal users: (portal and following project) or (portal and following task) [ + ('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]), + ] + + + + + + + + + Project/Task: portal users: portal user can edit with project sharing feature + + 0 + [ + ('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]), ] + + + + diff --git a/addons/project/static/src/project_sharing/components/chatter.js b/addons/project/static/src/project_sharing/components/chatter.js new file mode 100644 index 00000000000..e1ad0d48a25 --- /dev/null +++ b/addons/project/static/src/project_sharing/components/chatter.js @@ -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(); + }, +}); diff --git a/addons/project/static/src/project_sharing/components/chatter.xml b/addons/project/static/src/project_sharing/components/chatter.xml new file mode 100644 index 00000000000..aca16ce257a --- /dev/null +++ b/addons/project/static/src/project_sharing/components/chatter.xml @@ -0,0 +1,8 @@ + + + + + !!widget.options.res_id && widget.get('messages') || [] + + + diff --git a/addons/project/static/src/project_sharing/components/composer.js b/addons/project/static/src/project_sharing/components/composer.js new file mode 100644 index 00000000000..a7e75aad119 --- /dev/null +++ b/addons/project/static/src/project_sharing/components/composer.js @@ -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); + }, +}); diff --git a/addons/project/static/src/project_sharing/main.js b/addons/project/static/src/project_sharing/main.js new file mode 100644 index 00000000000..61458f6992c --- /dev/null +++ b/addons/project/static/src/project_sharing/main.js @@ -0,0 +1,5 @@ +/** @odoo-module **/ +import { startWebClient } from '@web/start'; +import { ProjectSharingWebClient } from './project_sharing'; + +startWebClient(ProjectSharingWebClient); diff --git a/addons/project/static/src/project_sharing/project_sharing.js b/addons/project/static/src/project_sharing/project_sharing.js new file mode 100644 index 00000000000..cf1affa05eb --- /dev/null +++ b/addons/project/static/src/project_sharing/project_sharing.js @@ -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'; diff --git a/addons/project/static/src/project_sharing/project_sharing.xml b/addons/project/static/src/project_sharing/project_sharing.xml new file mode 100644 index 00000000000..9f021f9530d --- /dev/null +++ b/addons/project/static/src/project_sharing/project_sharing.xml @@ -0,0 +1,14 @@ + + + + + + + + +
+ + + + + diff --git a/addons/project/static/src/project_sharing/views/form/controller.js b/addons/project/static/src/project_sharing/views/form/controller.js new file mode 100644 index 00000000000..b54daf2167e --- /dev/null +++ b/addons/project/static/src/project_sharing/views/form/controller.js @@ -0,0 +1,8 @@ +/** @odoo-module **/ + +import FormController from 'web.FormController'; +import BasicController from 'web.BasicController'; + +export default FormController.extend({ + _getActionMenuItems: BasicController.prototype._getActionMenuItems, +}); diff --git a/addons/project/static/src/project_sharing/views/form/renderer.js b/addons/project/static/src/project_sharing/views/form/renderer.js new file mode 100644 index 00000000000..f4acf457a66 --- /dev/null +++ b/addons/project/static/src/project_sharing/views/form/renderer.js @@ -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 = $('
'); + 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 $('
'); + } + return this._makeChatterContainerTarget(); + } + } + return this._super(...arguments); + }, +}); diff --git a/addons/project/static/src/project_sharing/views/form/view.js b/addons/project/static/src/project_sharing/views/form/view.js new file mode 100644 index 00000000000..36f7904679b --- /dev/null +++ b/addons/project/static/src/project_sharing/views/form/view.js @@ -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, + }), +}); diff --git a/addons/project/static/src/project_sharing/views/list/controller.js b/addons/project/static/src/project_sharing/views/list/controller.js new file mode 100644 index 00000000000..f6dbac12087 --- /dev/null +++ b/addons/project/static/src/project_sharing/views/list/controller.js @@ -0,0 +1,8 @@ +/** @odoo-module **/ + +import ListController from 'web.ListController'; +import BasicController from 'web.BasicController'; + +export default ListController.extend({ + _getActionMenuItems: BasicController.prototype._getActionMenuItems, +}); diff --git a/addons/project/static/src/project_sharing/views/list/view.js b/addons/project/static/src/project_sharing/views/list/view.js new file mode 100644 index 00000000000..4c202d29682 --- /dev/null +++ b/addons/project/static/src/project_sharing/views/list/view.js @@ -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, + }), +}); diff --git a/addons/project/static/src/project_sharing/views/registry.js b/addons/project/static/src/project_sharing/views/registry.js new file mode 100644 index 00000000000..62543381893 --- /dev/null +++ b/addons/project/static/src/project_sharing/views/registry.js @@ -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); diff --git a/addons/project/static/src/scss/project_sharing/chatter.scss b/addons/project/static/src/scss/project_sharing/chatter.scss new file mode 100644 index 00000000000..ab108ff1d22 --- /dev/null +++ b/addons/project/static/src/scss/project_sharing/chatter.scss @@ -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; + } + } + } +} + diff --git a/addons/project/tests/__init__.py b/addons/project/tests/__init__.py index 4e9ae83c97b..ee0446182b4 100644 --- a/addons/project/tests/__init__.py +++ b/addons/project/tests/__init__.py @@ -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 diff --git a/addons/project/tests/test_project_sharing.py b/addons/project/tests/test_project_sharing.py new file mode 100644 index 00000000000..04af79e5333 --- /dev/null +++ b/addons/project/tests/test_project_sharing.py @@ -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) diff --git a/addons/project/views/project_collaborator_views.xml b/addons/project/views/project_collaborator_views.xml new file mode 100644 index 00000000000..29b48984023 --- /dev/null +++ b/addons/project/views/project_collaborator_views.xml @@ -0,0 +1,53 @@ + + + + + project.collaborator.view.form + project.collaborator + +
+ + + + + + +
+
+
+ + + project.collaborator.view.tree + project.collaborator + + + + + + + + + + project.collaborator.view.search + project.collaborator + + + + + + + + + + + + + + Project Collaborators + project.collaborator + tree,form + [('project_id', '=', active_id)] + + + +
diff --git a/addons/project/views/project_portal_templates.xml b/addons/project/views/project_portal_templates.xml index b7196affb5a..ff01397ecae 100644 --- a/addons/project/views/project_portal_templates.xml +++ b/addons/project/views/project_portal_templates.xml @@ -6,14 +6,17 @@ Projects Projects - +
  • -
  • +
  • Tasks Tasks
  • - @@ -68,52 +71,65 @@ - + - - -
    - - - - - - - - -
    -
    - -
    -
    -
    Project Manager
    -
    -
    - Contact -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    Customer
    -
    -
    - Contact -
    -
    -
    -
    -
    -
    -
    -
    -
    + + Tasks + + + + + + + + + + diff --git a/addons/project/views/project_sharing_templates.xml b/addons/project/views/project_sharing_templates.xml new file mode 100644 index 00000000000..0dcc2e574a5 --- /dev/null +++ b/addons/project/views/project_sharing_templates.xml @@ -0,0 +1,33 @@ + + + + + +