diff --git a/project_customer_access/README.rst b/project_customer_access/README.rst new file mode 100644 index 0000000..42dae3a --- /dev/null +++ b/project_customer_access/README.rst @@ -0,0 +1,34 @@ +.. image:: https://img.shields.io/badge/licence-AGPL--3-blue.svg + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 + +========================= +Project Customer Access +========================= + +Project menu and views for customers accessing your own ERP. +Module based on : +- `cross_connect_server `_ to make the connection between the two ERP +- `base_group_backend `_ to give restricted backend access to the customer. + + +Configuration +============= + +The customer's user needs to be part of one of the security groups "Project Access Customer" to access its Tasks with restricted access. + +As these groups are based on "User types / Backend UI user", the only way to add the user to these groups through the UI will be through the group's form view itself, changing the "Users" tab. + + +Usage +===== + +#. Create a user with the security group "Project Access Customer / User" or "Manager" +#. Relate the user and the projects he needs to access to the same "Cross Connect Client" +#. Connect as the customer's and go to its "Project" menu + + +Contributors +------------ + +* Clément Mombereau diff --git a/project_customer_access/__init__.py b/project_customer_access/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/project_customer_access/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/project_customer_access/__manifest__.py b/project_customer_access/__manifest__.py new file mode 100644 index 0000000..3b06b6b --- /dev/null +++ b/project_customer_access/__manifest__.py @@ -0,0 +1,27 @@ +# Copyright 2024 Akretion +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Project Customer Access", + "summary": """Project menu and views for customers accessing your own ERP""", + "version": "16.0.1.0.0", + "license": "AGPL-3", + "author": "Akretion", + "website": "http://akretion.com", + "depends": [ + "date_range", + # https://github.com/akretion/ak-odoo-incubator + "project_sprint", + # https://github.com/OCA/server-backend + "base_group_backend", + # https://github.com/OCA/server-auth + "cross_connect_server", + ], + "data": [ + "security/res_groups.xml", + "security/ir.model.access.csv", + "security/project_customer_access_security.xml", + "views/project_task_views.xml", + ], + "demo": ["demo/project_customer_access_demo.xml"], +} diff --git a/project_customer_access/demo/project_customer_access_demo.xml b/project_customer_access/demo/project_customer_access_demo.xml new file mode 100644 index 0000000..b1efc41 --- /dev/null +++ b/project_customer_access/demo/project_customer_access_demo.xml @@ -0,0 +1,18 @@ + + + + + + Cross Connect Server Endpoint Demo + /api + cross_connect + + + + Test Client + + server-api-key + + + + \ No newline at end of file diff --git a/project_customer_access/models/__init__.py b/project_customer_access/models/__init__.py new file mode 100644 index 0000000..351a3ad --- /dev/null +++ b/project_customer_access/models/__init__.py @@ -0,0 +1 @@ +from . import project diff --git a/project_customer_access/models/project.py b/project_customer_access/models/project.py new file mode 100644 index 0000000..8d98d65 --- /dev/null +++ b/project_customer_access/models/project.py @@ -0,0 +1,102 @@ +# Copyright 2024 Akretion +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import json + +from lxml import etree +from odoo import api, fields, models + + +class ProjectProject(models.Model): + _inherit = "project.project" + + cross_connect_client_id = fields.Many2one("cross.connect.client") + + +class ProjectTask(models.Model): + _inherit = "project.task" + + origin_url = fields.Char() + origin_db = fields.Char() + origin_name = fields.Char() + cross_connect_client_id = fields.Many2one("cross.connect.client", related="project_id.cross_connect_client_id", store=True) + user_ids = fields.Many2many(domain="[('cross_connect_client_id', 'in', [False, cross_connect_client_id])]") + + def _get_customer_access_view_ids(self): + form_id = self.env.ref("project_customer_access.view_task_form") + kanban_id = self.env.ref("project_customer_access.view_task_kanban") + search_id = self.env.ref("project_customer_access.view_task_search_form") + return form_id | kanban_id | search_id + + def _get_editable_fields_customer(self): + return ["name", "description"] + + def _get_editable_fields_manager(self): + return ["name", "description", "tag_ids", "stage_id", "project_id", "user_ids"] + + def _get_readonly_value(self, field): + field_name = field.attrib.get("name") + + if self.env.user.has_group("project_customer_access.group_manager"): + return field_name not in self._get_editable_fields_manager() + + elif self.env.user.has_group("project_customer_access.group_customer"): + if field_name == "project_id": + # project_id must be readonly only after creation + return [("create_date", "!=", False)] + elif field_name in self._get_editable_fields_customer(): + # Allows the customers who are not manager to modify only their tasks + return [("user_ids", "not in", self.env.user.id)] + else: + return True + + else: + return True + + @api.model + def get_view(self, view_id=None, view_type="form", **options): + res = super().get_view(view_id=view_id, view_type=view_type, **options) + customer_access_view_ids = self._get_customer_access_view_ids() + if view_id in customer_access_view_ids.ids: + doc = etree.XML(res["arch"]) + if view_type in ["form", "kanban"]: + for field in doc.xpath("//field[@name][not(ancestor::field)]"): + modifiers = json.loads( + field.attrib.get("modifiers", '{"readonly": false}') + ) + if modifiers.get("readonly") is not True: + modifiers["readonly"] = self._get_readonly_value(field) + + field.attrib["modifiers"] = json.dumps(modifiers) + + # List all accessible projects in filters for customers project users + + if view_type == "search": + projects = self.env["project.project"].search([]) + node = doc.xpath("//search")[0] + idx = node.index(node.xpath("//filter[@name='unassigned']")[0]) + for project in reversed(projects): + elem = etree.Element( + "filter", + string=project.name, + name=f"project_{project.id}", + domain=f"[('project_id', '=', {project.id})]", + ) + node.insert(idx, elem) + if projects: + node.insert(idx, etree.Element("separator")) + + res["arch"] = etree.tostring(doc, pretty_print=True) + + return res + + def write(self, values): + if ( + self.env.user.has_group("project_customer_access.group_manager") + and "project_id" in values + ): + # We need to give sudo access to the manager to be able to change project_id + # on the task's timesheets even if they are not its own. + self.env = self.sudo().env + + return super().write(values) diff --git a/project_customer_access/security/ir.model.access.csv b/project_customer_access/security/ir.model.access.csv new file mode 100644 index 0000000..58061b0 --- /dev/null +++ b/project_customer_access/security/ir.model.access.csv @@ -0,0 +1,8 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_customer_project,access_customer_project,project.model_project_project,project_customer_access.group_customer,1,0,0,0 +access_customer_task,access_customer_task,project.model_project_task,project_customer_access.group_customer,1,1,1,0 +access_customer_task_type,access_customer_task_type,project.model_project_task_type,project_customer_access.group_customer,1,0,0,0 +access_customer_internal_task_type,access_customer_internal_task_type,project.model_project_task_stage_personal,project_customer_access.group_customer,1,1,0,0 +access_customer_task_rating,access_customer_task_rating,rating.model_rating_rating,project_customer_access.group_customer,1,0,0,0 +access_customer_date_range,access_customer_date_range,date_range.model_date_range,project_customer_access.group_customer,1,0,0,0 +access_customer_sprint,access_customer_sprint,project_sprint.model_project_sprint,project_customer_access.group_customer,1,0,0,0 diff --git a/project_customer_access/security/project_customer_access_security.xml b/project_customer_access/security/project_customer_access_security.xml new file mode 100644 index 0000000..3107f53 --- /dev/null +++ b/project_customer_access/security/project_customer_access_security.xml @@ -0,0 +1,41 @@ + + + + + + Project Customer Access: Tasks + + + [('project_id.cross_connect_client_id', '=', user.cross_connect_client_id.id)] + + + + + + + + + + Project Customer Access: Projects + + + [('cross_connect_client_id', '=', user.cross_connect_client_id.id)] + + + + + + Project Customer Access: Users + + + [('cross_connect_client_id', 'in', [False, user.cross_connect_client_id.id])] + + + + + + + + + + diff --git a/project_customer_access/security/res_groups.xml b/project_customer_access/security/res_groups.xml new file mode 100644 index 0000000..5de5a3d --- /dev/null +++ b/project_customer_access/security/res_groups.xml @@ -0,0 +1,18 @@ + + + + Project Access Customer + 90 + + + Support User + + + + + Support Manager + + + + + diff --git a/project_customer_access/static/description/icon.svg b/project_customer_access/static/description/icon.svg new file mode 100644 index 0000000..7fd6ba9 --- /dev/null +++ b/project_customer_access/static/description/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/project_customer_access/tests/__init__.py b/project_customer_access/tests/__init__.py new file mode 100644 index 0000000..fb93ebf --- /dev/null +++ b/project_customer_access/tests/__init__.py @@ -0,0 +1 @@ +from . import test_project_customer_access diff --git a/project_customer_access/tests/test_project_customer_access.py b/project_customer_access/tests/test_project_customer_access.py new file mode 100644 index 0000000..242a0a0 --- /dev/null +++ b/project_customer_access/tests/test_project_customer_access.py @@ -0,0 +1,128 @@ +# Copyright 2024 Akretion +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import Command +from odoo.exceptions import AccessError +from odoo.tests.common import Form, TransactionCase, new_test_user + + +class TestProjectCustomerAccess(TransactionCase): + + def setUp(self): + super().setUp() + self.customer = new_test_user( + self.env, + login="test_customer", + groups="project_customer_access.group_customer", + ) + self.manager = new_test_user( + self.env, + login="test_manager", + groups="project_customer_access.group_manager", + ) + + self.form_view = "project_customer_access.view_task_form" + self.project1 = self.env.ref("project.project_project_1") + self.project2 = self.env.ref("project.project_project_2") + self.project_ids = self.project1 | self.project2 + self.task = self.env.ref("project.project_1_task_1") + self.tag = self.env.ref("project.project_tags_00") + self.stage = self.env.ref("project.project_stage_3") + + self.task_customer = self.env["project.task"].create( + { + "name": "Test", + "project_id": self.project1.id, + "user_ids": [Command.link(self.customer.id)], + } + ) + + # Cross connection made by other module + self.endpoint = self.env["fastapi.endpoint"].create( + { + "name": "Cross Connect Server Endpoint", + "root_path": "/api", + "app": "cross_connect", + } + ) + self.client = self.env["cross.connect.client"].create( + { + "name": "Test Client", + "endpoint_id": self.endpoint.id, + "api_key": "server-api-key", + } + ) + (self.customer | self.manager).write( + {"cross_connect_client_id": self.client.id} + ) + self.project_ids.write({"cross_connect_client_id": self.client.id}) + + def test_visible_projects(self): + customer_proj = self.env["project.project"].with_user(self.customer).search([]) + manager_proj = self.env["project.project"].with_user(self.manager).search([]) + + self.assertEqual(customer_proj, self.project_ids) + self.assertEqual(manager_proj, self.project_ids) + + def test_visible_tasks(self): + task_ids = self.env["project.task"].search( + [("project_id", "in", self.project_ids.ids)] + ) + customer_tasks = self.env["project.task"].with_user(self.customer).search([]) + manager_tasks = self.env["project.task"].with_user(self.manager).search([]) + + self.assertEqual(customer_tasks, task_ids) + self.assertEqual(manager_tasks, task_ids) + + def test_edit_own_task_customer(self): + task_id = self.task_customer.with_user(self.customer) + with Form(task_id, view=self.form_view) as f: + f.name = "Test" + f.description = "Test" + with self.assertRaisesRegex(AssertionError, "can't write on readonly"): + f.project_id = self.project2 + + self.assertEqual(task_id.name, "Test") + self.assertIn("Test", str(task_id.description)) + + def test_edit_other_task_customer(self): + task_id = self.task.with_user(self.customer) + task_form = Form(task_id, view=self.form_view) + with self.assertRaisesRegex(AssertionError, "can't write on readonly"): + task_form.name = "Test" + + def test_edit_task_manager(self): + task_id = self.task.with_user(self.manager) + with Form(task_id, view=self.form_view) as f: + f.name = "Test" + f.description = "Test" + f.tag_ids.add(self.tag) + f.stage_id = self.stage + f.project_id = self.project2 + + self.assertEqual(task_id.name, "Test") + self.assertIn("Test", str(task_id.description)) + self.assertEqual(task_id.tag_ids, self.tag) + self.assertEqual(task_id.stage_id, self.stage) + self.assertEqual(task_id.project_id, self.project2) + + def test_no_unlink_manager(self): + with self.assertRaisesRegex(AccessError, "You are not allowed to delete"): + self.task_customer.with_user(self.manager).unlink() + + def test_create_task_customer(self): + f = Form(self.env["project.task"].with_user(self.customer), view=self.form_view) + f.name = "New" + f.description = "New" + f.project_id = self.project1 + task_id = f.save() + + self.assertEqual(task_id.name, "New") + self.assertIn("New", str(task_id.description)) + self.assertEqual(task_id.project_id, self.project1) + + def test_create_task_customer_no_project(self): + f = Form(self.env["project.task"].with_user(self.customer), view=self.form_view) + f.name = "New" + with self.assertRaisesRegex(AssertionError, "project_id is a required field"): + task_id = f.save() diff --git a/project_customer_access/views/project_task_views.xml b/project_customer_access/views/project_task_views.xml new file mode 100644 index 0000000..6f3a5f3 --- /dev/null +++ b/project_customer_access/views/project_task_views.xml @@ -0,0 +1,380 @@ + + + + + + + + + project.task.search.form (in project_customer_access) + project.task + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + project.task.form (in project_customer_access) + project.task + + +
+ + + + + +
+ +
+ + +
+

+
+ + +
+ +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + +
+ +
+
+ + + project.task.kanban (in project_customer_access) + project.task + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + + +
+ +
+ +
+
+ + + + + + + + + +
+ +
+
+ +
+ +
+
+ +
+
+
+
+ +
+
+ + +
+
+
+
+
+
+
+
+
+
+ + + project.task.tree (in project_customer_access) + project.task + + + + + + + + + + + + + + + + + + + + + + + + + + + + Tasks + project.task + kanban,tree,form + {'search_default_open_tasks': 1} + + +

+ No tasks found. Let's create one! +

+

+ Keep track of the progress of your tasks from creation to completion.
+ Collaborate efficiently by chatting in real-time or via email. +

+
+
+ + + + kanban + + + + + + tree + + + + + + form + + + + + + + + project.project.form in project_customer_access + project.project + + + + + + + + + + + res.users.form in project_customer_access + res.users + + + + + + + + + + project.task.form + project.task + + + + + + + + + + + +
diff --git a/setup/project_customer_access/odoo/addons/project_customer_access b/setup/project_customer_access/odoo/addons/project_customer_access new file mode 120000 index 0000000..a001215 --- /dev/null +++ b/setup/project_customer_access/odoo/addons/project_customer_access @@ -0,0 +1 @@ +../../../../project_customer_access \ No newline at end of file diff --git a/setup/project_customer_access/setup.py b/setup/project_customer_access/setup.py new file mode 100644 index 0000000..28c57bb --- /dev/null +++ b/setup/project_customer_access/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)