diff --git a/addons/hr_timesheet/controllers/project.py b/addons/hr_timesheet/controllers/project.py
index cf9afc7b4c3..970ae2fdb0f 100644
--- a/addons/hr_timesheet/controllers/project.py
+++ b/addons/hr_timesheet/controllers/project.py
@@ -10,6 +10,31 @@ from odoo.addons.project.controllers.portal import CustomerPortal
class ProjectCustomerPortal(CustomerPortal):
+ def _prepare_project_sharing_session_info(self, project):
+ session_info = super()._prepare_project_sharing_session_info(project)
+
+ company = project.company_id
+ timesheet_encode_uom = company.timesheet_encode_uom_id
+ project_time_mode_uom = company.project_time_mode_id
+ session_info['user_companies']['allowed_companies'][company.id].update(
+ timesheet_uom_id=timesheet_encode_uom.id,
+ timesheet_uom_factor=project_time_mode_uom._compute_quantity(
+ 1.0,
+ timesheet_encode_uom,
+ round=False
+ ),
+ )
+ session_info['uom_ids'] = {
+ uom.id:
+ {
+ 'id': uom.id,
+ 'name': uom.name,
+ 'rounding': uom.rounding,
+ 'timesheet_widget': uom.timesheet_widget,
+ } for uom in [timesheet_encode_uom, project_time_mode_uom]
+ }
+ return session_info
+
def _task_get_page_view_values(self, task, access_token, **kwargs):
values = super(ProjectCustomerPortal, self)._task_get_page_view_values(task, access_token, **kwargs)
domain = request.env['account.analytic.line']._timesheet_get_portal_domain()
diff --git a/addons/hr_timesheet/models/hr_timesheet.py b/addons/hr_timesheet/models/hr_timesheet.py
index 3e756dd201c..c8a888bc27e 100644
--- a/addons/hr_timesheet/models/hr_timesheet.py
+++ b/addons/hr_timesheet/models/hr_timesheet.py
@@ -163,9 +163,10 @@ class AccountAnalyticLine(models.Model):
def _apply_time_label(self, view_arch, related_model):
doc = etree.XML(view_arch)
Model = self.env[related_model]
- encoding_uom = self.env.company.timesheet_encode_uom_id
+ # Just fetch the name of the uom in `timesheet_encode_uom_id` of the current company
+ encoding_uom_name = self.env.company.timesheet_encode_uom_id.with_context(prefetch_fields=False).sudo().name
for node in doc.xpath("//field[@widget='timesheet_uom'][not(@string)] | //field[@widget='timesheet_uom_no_toggle'][not(@string)]"):
- name_with_uom = re.sub(_('Hours') + "|Hours", encoding_uom.name or '', Model._fields[node.get('name')]._description_string(self.env), flags=re.IGNORECASE)
+ name_with_uom = re.sub(_('Hours') + "|Hours", encoding_uom_name or '', Model._fields[node.get('name')]._description_string(self.env), flags=re.IGNORECASE)
node.set('string', name_with_uom)
return etree.tostring(doc, encoding='unicode')
diff --git a/addons/hr_timesheet/views/project_sharing_views.xml b/addons/hr_timesheet/views/project_sharing_views.xml
index 95f82d8be2e..7a9798821a7 100644
--- a/addons/hr_timesheet/views/project_sharing_views.xml
+++ b/addons/hr_timesheet/views/project_sharing_views.xml
@@ -25,11 +25,11 @@
-
+
-
+
@@ -37,18 +37,19 @@
-
+
-
+
+
-
+
@@ -154,8 +155,8 @@
-
-
+
+
diff --git a/addons/portal/static/src/js/portal_chatter.js b/addons/portal/static/src/js/portal_chatter.js
index 0f379460d06..ec399e5075d 100644
--- a/addons/portal/static/src/js/portal_chatter.js
+++ b/addons/portal/static/src/js/portal_chatter.js
@@ -122,7 +122,7 @@ var PortalChatter = publicWidget.Widget.extend({
*/
_setOptions: function (options) {
// underscorize the camelcased option keys
- const defaultOptions = {
+ const defaultOptions = Object.assign({
'allow_composer': true,
'display_composer': false,
'csrf_token': odoo.csrf_token,
@@ -137,7 +137,7 @@ var PortalChatter = publicWidget.Widget.extend({
'pid': false,
'domain': [],
'two_columns': false,
- };
+ }, this.options || {});
this.options = Object.entries(options).reduce(
(acc, [key, value]) => {
diff --git a/addons/project/__manifest__.py b/addons/project/__manifest__.py
index 5d9fc081dd2..4ce0c2ef4cc 100644
--- a/addons/project/__manifest__.py
+++ b/addons/project/__manifest__.py
@@ -87,7 +87,9 @@
'project/static/src/js/portal_rating.js',
],
'web.assets_qweb': [
- 'project/static/src/**/*.xml',
+ 'project/static/src/xml/**/*',
+ 'project/static/src/burndown_chart/**/*.xml',
+ 'project/static/src/project_control_panel/**/*.xml',
],
'web.qunit_suite_tests': [
'project/static/tests/burndown_chart_tests.js',
@@ -115,6 +117,7 @@
'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/search/favorite_menu/custom_favorite_item.xml',
'project/static/src/project_sharing/**/*.js',
'project/static/src/scss/project_sharing/*',
'web/static/src/start.js',
diff --git a/addons/project/controllers/portal.py b/addons/project/controllers/portal.py
index e0f1cc09b6c..63cd3bd2a22 100644
--- a/addons/project/controllers/portal.py
+++ b/addons/project/controllers/portal.py
@@ -159,12 +159,7 @@ class ProjectCustomerPortal(CustomerPortal):
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()
-
+ def _prepare_project_sharing_session_info(self, project):
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 []
@@ -190,11 +185,20 @@ class ProjectCustomerPortal(CustomerPortal):
},
},
},
+ # FIXME: See if we prefer to give only the currency that the portal user just need to see the correct information in project sharing
+ currencies=request.env['ir.http'].get_currencies(),
)
+ return session_info
+
+ @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()
return request.render(
'project.project_sharing_embed',
- {'session_info': session_info},
+ {'session_info': self._prepare_project_sharing_session_info(project)},
)
@http.route('/my/project//task/', type='http', auth='public', website=True)
@@ -206,9 +210,9 @@ class ProjectCustomerPortal(CustomerPortal):
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)
+ task_sudo = Task.search([('project_id', '=', project_id), ('id', '=', task_id)], limit=1).sudo()
+ task_sudo.attachment_ids.generate_access_token()
+ values = self._task_get_page_view_values(task_sudo, access_token, project=project_sudo, **kw)
values['project'] = project_sudo
return request.render("project.portal_my_task", values)
diff --git a/addons/project/models/project.py b/addons/project/models/project.py
index 1325f96d586..0e32155746c 100644
--- a/addons/project/models/project.py
+++ b/addons/project/models/project.py
@@ -38,8 +38,10 @@ PROJECT_TASK_READABLE_FIELDS = {
'company_id',
'displayed_image_id',
'display_name',
- 'priority',
'portal_user_names',
+ 'legend_normal',
+ 'legend_blocked',
+ 'legend_done',
}
PROJECT_TASK_WRITABLE_FIELDS = {
@@ -54,6 +56,7 @@ PROJECT_TASK_WRITABLE_FIELDS = {
'kanban_state',
'child_ids',
'parent_id',
+ 'priority',
}
class ProjectTaskType(models.Model):
diff --git a/addons/project/static/src/project_sharing/components/favorite_menu_registry.js b/addons/project/static/src/project_sharing/components/favorite_menu_registry.js
new file mode 100644
index 00000000000..13b05bf1556
--- /dev/null
+++ b/addons/project/static/src/project_sharing/components/favorite_menu_registry.js
@@ -0,0 +1,35 @@
+/** @odoo-module **/
+
+import FavoriteMenuLegacy from 'web.FavoriteMenu';
+import CustomFavoriteItemLegacy from 'web.CustomFavoriteItem';
+import { registry } from "@web/core/registry";
+
+
+/**
+ * Remove all components contained in the favorite menu registry except the CustomFavoriteItem
+ * component for only the project sharing feature.
+ */
+export function prepareFavoriteMenuRegister() {
+ let customFavoriteItemKey = 'favorite-generator-menu';
+ const keys = FavoriteMenuLegacy.registry.keys().filter(key => key !== customFavoriteItemKey);
+ FavoriteMenuLegacy.registry = Object.assign(FavoriteMenuLegacy.registry, {
+ map: {},
+ _scoreMapping: {},
+ _sortedKeys: null,
+ });
+ FavoriteMenuLegacy.registry.add(customFavoriteItemKey, CustomFavoriteItemLegacy, 0);
+ // notify the listeners, we keep only one key in this registry.
+ for (const key of keys) {
+ for (const callback of FavoriteMenuLegacy.registry.listeners) {
+ callback(key, undefined);
+ }
+ }
+
+ customFavoriteItemKey = 'custom-favorite-item';
+ const favoriteMenuRegistry = registry.category("favoriteMenu");
+ for (const [key, _] of favoriteMenuRegistry.getEntries()) {
+ if (key !== customFavoriteItemKey) {
+ favoriteMenuRegistry.remove(key);
+ }
+ }
+}
diff --git a/addons/project/static/src/project_sharing/main.js b/addons/project/static/src/project_sharing/main.js
index 61458f6992c..9af1f6eeb8b 100644
--- a/addons/project/static/src/project_sharing/main.js
+++ b/addons/project/static/src/project_sharing/main.js
@@ -1,5 +1,7 @@
/** @odoo-module **/
import { startWebClient } from '@web/start';
import { ProjectSharingWebClient } from './project_sharing';
+import { prepareFavoriteMenuRegister } from './components/favorite_menu_registry';
+prepareFavoriteMenuRegister();
startWebClient(ProjectSharingWebClient);
diff --git a/addons/project/static/src/project_sharing/search/favorite_menu/custom_favorite_item.xml b/addons/project/static/src/project_sharing/search/favorite_menu/custom_favorite_item.xml
new file mode 100644
index 00000000000..e03c1be7bc8
--- /dev/null
+++ b/addons/project/static/src/project_sharing/search/favorite_menu/custom_favorite_item.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/addons/project/static/src/project_sharing/views/list/view.js b/addons/project/static/src/project_sharing/views/list/view.js
index 4c202d29682..843f7861303 100644
--- a/addons/project/static/src/project_sharing/views/list/view.js
+++ b/addons/project/static/src/project_sharing/views/list/view.js
@@ -7,4 +7,40 @@ export default ListView.extend({
config: Object.assign({}, ListView.prototype.config, {
Controller,
}),
+
+ init: function (viewInfo, params) {
+ return this._super(viewInfo, {...params, hasSelectors: false});
+ },
+ getRenderer (parent, state) {
+ const columnInvisibleFields = {};
+ for (const child of this.arch.children) {
+ if (child.attrs && child.attrs.modifiers && child.attrs.modifiers.column_invisible) {
+ columnInvisibleFields[child.attrs.name] = child.attrs.modifiers.column_invisible;
+ }
+ }
+ this.rendererParams.columnInvisibleFields = Object.entries(columnInvisibleFields).reduce((acc, entry) => {
+ const [fieldName, domains] = entry;
+ if (domains instanceof Array && state.data.length) {
+ const record = state.data[0];
+ const data = Object.entries(record.data).reduce((acc, entry) => {
+ const [fieldName, value] = entry;
+ const field = record.fields[fieldName];
+ if (field.type === 'one2many' || field.type === 'many2many') {
+ if (value instanceof Object && value.type === 'list') {
+ acc[fieldName] = value.id;
+ }
+ }
+ return acc;
+ }, record.data);
+ if (Object.keys(data).length) {
+ // We assume the field in this domain is a related to the project shared.
+ acc[fieldName] = this.model._evalModifiers({ ...record, data }, { column_invisible: domains }).column_invisible;
+ }
+ } else {
+ acc[fieldName] = domains;
+ }
+ return acc;
+ }, {});
+ return this._super(parent, state);
+ },
});
diff --git a/addons/project/static/src/scss/project_sharing/chatter.scss b/addons/project/static/src/scss/project_sharing/chatter.scss
index ab108ff1d22..5f63bb31f59 100644
--- a/addons/project/static/src/scss/project_sharing/chatter.scss
+++ b/addons/project/static/src/scss/project_sharing/chatter.scss
@@ -2,7 +2,6 @@
display: flex;
background-color: $white;
border-color: $border-color;
- height: fit-content;
.o_portal_chatter {
width: 100%;
diff --git a/addons/project/static/tests/tours/project_sharing_tour.js b/addons/project/static/tests/tours/project_sharing_tour.js
index ea9fb09c261..b744f44f943 100644
--- a/addons/project/static/tests/tours/project_sharing_tour.js
+++ b/addons/project/static/tests/tours/project_sharing_tour.js
@@ -69,6 +69,24 @@ tour.register('project_sharing_tour', {
}, {
trigger: 'ol.breadcrumb > li.o_back_button > a:contains(Project Sharing)',
content: 'Go back to the kanban view',
+}, {
+ trigger: '.o_filter_menu > button',
+ content: 'click on filter menu in the search view',
+}, {
+ trigger: '.o_filter_menu > .dropdown-menu > .dropdown-item:first-child',
+ content: 'click on the first item in the filter menu',
+}, {
+ trigger: '.o_group_by_menu > button',
+ content: 'click on group by menu in the search view',
+}, {
+ trigger: '.o_group_by_menu > .dropdown-menu > .dropdown-item:first-child',
+ content: 'click on the first item in the group by menu',
+}, {
+ trigger: '.o_favorite_menu > button',
+ content: 'click on the favorite menu in the search view',
+}, {
+ trigger: '.o_favorite_menu .o_add_favorite > button',
+ content: 'click to "save current search" button in favorite menu',
}, {
trigger: 'button.o_switch_view.o_list',
content: 'Go to the list view',
@@ -109,6 +127,24 @@ tour.register('portal_project_sharing_tour', {
}, {
trigger: 'ol.breadcrumb > li.o_back_button > a:contains(Project Sharing)',
content: 'Go back to the kanban view',
+}, {
+ trigger: '.o_filter_menu > button',
+ content: 'click on filter menu in the search view',
+}, {
+ trigger: '.o_filter_menu > .dropdown-menu > .dropdown-item:first-child',
+ content: 'click on the first item in the filter menu',
+}, {
+ trigger: '.o_group_by_menu > button',
+ content: 'click on group by menu in the search view',
+}, {
+ trigger: '.o_group_by_menu > .dropdown-menu > .dropdown-item:first-child',
+ content: 'click on the first item in the group by menu',
+}, {
+ trigger: '.o_favorite_menu > button',
+ content: 'click on the favorite menu in the search view',
+}, {
+ trigger: '.o_favorite_menu .o_add_favorite > button',
+ content: 'click to "save current search" button in favorite menu',
}, {
trigger: 'button.o_switch_view.o_list',
content: 'Go to the list view',
diff --git a/addons/project/tests/__init__.py b/addons/project/tests/__init__.py
index 6f85611241d..8aea3e56d27 100644
--- a/addons/project/tests/__init__.py
+++ b/addons/project/tests/__init__.py
@@ -7,6 +7,7 @@ from . import test_project_config
from . import test_project_flow
from . import test_project_recurrence
from . import test_project_sharing
+from . import test_project_sharing_portal_access
from . import test_project_sharing_ui
from . import test_project_subtasks
from . import test_project_ui
diff --git a/addons/project/tests/test_project_sharing.py b/addons/project/tests/test_project_sharing.py
index 0a4828b4a96..5846fc77f0e 100644
--- a/addons/project/tests/test_project_sharing.py
+++ b/addons/project/tests/test_project_sharing.py
@@ -51,10 +51,12 @@ class TestProjectSharingCommon(TestProjectCommon):
'project_id': cls.project_portal.id,
})
+ cls.project_sharing_form_view_xml_id = 'project.project_sharing_project_task_view_form'
+
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"
+ view=self.project_sharing_form_view_xml_id
)
@tagged('project_sharing')
diff --git a/addons/project/tests/test_project_sharing_portal_access.py b/addons/project/tests/test_project_sharing_portal_access.py
new file mode 100644
index 00000000000..f7cfed90e29
--- /dev/null
+++ b/addons/project/tests/test_project_sharing_portal_access.py
@@ -0,0 +1,76 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from collections import OrderedDict
+from odoo import Command
+from odoo.exceptions import AccessError
+from odoo.tests import tagged
+
+from .test_project_sharing import TestProjectSharingCommon
+
+
+@tagged('post_install', '-at_install')
+class TestProjectSharingPortalAccess(TestProjectSharingCommon):
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ project_share_wizard = cls.env['project.share.wizard'].create({
+ 'access_mode': 'edit',
+ 'res_model': 'project.project',
+ 'res_id': cls.project_portal.id,
+ 'partner_ids': [
+ Command.link(cls.partner_portal.id),
+ ],
+ })
+ project_share_wizard.action_send_mail()
+
+ Task = cls.env['project.task']
+ cls.read_protected_fields_task = OrderedDict([
+ (k, v)
+ for k, v in Task._fields.items()
+ if k in Task.SELF_READABLE_FIELDS
+ ])
+ cls.write_protected_fields_task = OrderedDict([
+ (k, v)
+ for k, v in Task._fields.items()
+ if k in Task.SELF_WRITABLE_FIELDS
+ ])
+ cls.readonly_protected_fields_task = OrderedDict([
+ (k, v)
+ for k, v in Task._fields.items()
+ if k in Task.SELF_READABLE_FIELDS and k not in Task.SELF_WRITABLE_FIELDS
+ ])
+ cls.other_fields_task = OrderedDict([
+ (k, v)
+ for k, v in Task._fields.items()
+ if k not in Task.SELF_READABLE_FIELDS
+ ])
+
+ def test_readonly_fields(self):
+ """ The fields are not writeable should not be editable by the portal user. """
+ view_infos = self.task_portal.fields_view_get(view_id=self.env.ref(self.project_sharing_form_view_xml_id).id)
+ project_task_fields = {
+ field_name
+ for field_name, field_attrs in view_infos['fields'].items()
+ if field_name not in self.write_protected_fields_task
+ }
+ with self.get_project_sharing_form_view(self.task_portal, self.user_portal) as form:
+ for field in project_task_fields:
+ with self.assertRaises(AssertionError, msg="Field '%s' should be readonly in the project sharing form view "):
+ form.__setattr__(field, 'coucou')
+
+ def test_read_task_with_portal_user(self):
+ self.task_portal.with_user(self.user_portal).read(self.read_protected_fields_task)
+
+ with self.assertRaises(AccessError):
+ self.task_portal.with_user(self.user_portal).read(self.other_fields_task)
+
+ def test_write_with_portal_user(self):
+ for field in self.readonly_protected_fields_task:
+ with self.assertRaises(AccessError):
+ self.task_portal.with_user(self.user_portal).write({field: 'dummy'})
+
+ for field in self.other_fields_task:
+ with self.assertRaises(AccessError):
+ self.task_portal.with_user(self.user_portal).write({field: 'dummy'})
diff --git a/addons/project/views/project_sharing_views.xml b/addons/project/views/project_sharing_views.xml
index 01264b38385..2b6e452add4 100644
--- a/addons/project/views/project_sharing_views.xml
+++ b/addons/project/views/project_sharing_views.xml
@@ -27,6 +27,7 @@
on_create="quick_create"
quick_create_view="project.project_sharing_quick_create_task_form"
archivable="0"
+ import="0"
>
@@ -42,6 +43,9 @@
+
+
+
@@ -70,7 +74,8 @@
@@ -186,7 +197,7 @@
-
+
@@ -232,6 +243,7 @@
+
-
+
+
-
+
+
diff --git a/addons/project/views/project_views.xml b/addons/project/views/project_views.xml
index 86acea86a1b..3b802f21ad0 100644
--- a/addons/project/views/project_views.xml
+++ b/addons/project/views/project_views.xml
@@ -809,7 +809,7 @@
+ attrs="{'invisible' : "[('user_ids', '=', uid)]"}" data-hotkey="q"/>
@@ -1100,7 +1100,7 @@
Private
-
+
diff --git a/addons/sale_project/views/project_sharing_views.xml b/addons/sale_project/views/project_sharing_views.xml
index d4776bfcdc6..85d4b1d8ca6 100644
--- a/addons/sale_project/views/project_sharing_views.xml
+++ b/addons/sale_project/views/project_sharing_views.xml
@@ -8,9 +8,9 @@
-