Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions project_customer_access/README.rst
Original file line number Diff line number Diff line change
@@ -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 <https://github.com/OCA/server-auth/pull/734>`_ to make the connection between the two ERP
- `base_group_backend <https://github.com/OCA/server-backend/tree/16.0/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 <clement.mombereau@akretion.com>
1 change: 1 addition & 0 deletions project_customer_access/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import models
27 changes: 27 additions & 0 deletions project_customer_access/__manifest__.py
Original file line number Diff line number Diff line change
@@ -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"],
}
18 changes: 18 additions & 0 deletions project_customer_access/demo/project_customer_access_demo.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>

<record id="endpoint_1" model="fastapi.endpoint">
<field name="name">Cross Connect Server Endpoint Demo</field>
<field name="root_path">/api</field>
<field name="app">cross_connect</field>
</record>

<record id="cross_connect_client_1" model="cross.connect.client">
<field name="name">Test Client</field>
<field name="endpoint_id" ref="endpoint_1"/>
<field name="api_key">server-api-key</field>
</record>

</data>
</odoo>
1 change: 1 addition & 0 deletions project_customer_access/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import project
102 changes: 102 additions & 0 deletions project_customer_access/models/project.py
Original file line number Diff line number Diff line change
@@ -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)
8 changes: 8 additions & 0 deletions project_customer_access/security/ir.model.access.csv
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">

<record id="rule_project_customer_access_tasks" model="ir.rule">
<field name="name">Project Customer Access: Tasks</field>
<field name="model_id" ref="project.model_project_task"/>
<field name="domain_force">
[('project_id.cross_connect_client_id', '=', user.cross_connect_client_id.id)]
</field>
<field name="perm_create" eval="0" />
<field name="perm_read" eval="1" />
<field name="perm_write" eval="0" />
<field name="perm_unlink" eval="0" />
<field name="groups" eval="[(4,ref('project_customer_access.group_customer'))]"/>
</record>

<record id="rule_project_customer_access_projects" model="ir.rule">
<field name="name">Project Customer Access: Projects</field>
<field name="model_id" ref="project.model_project_project"/>
<field name="domain_force">
[('cross_connect_client_id', '=', user.cross_connect_client_id.id)]
</field>
<field name="groups" eval="[(4,ref('project_customer_access.group_customer'))]"/>
</record>

<record id="rule_project_customer_access_users" model="ir.rule">
<field name="name">Project Customer Access: Users</field>
<field name="model_id" ref="base.model_res_users"/>
<field name="domain_force">
[('cross_connect_client_id', 'in', [False, user.cross_connect_client_id.id])]
</field>
<field name="perm_create" eval="0" />
<field name="perm_read" eval="1" />
<field name="perm_write" eval="0" />
<field name="perm_unlink" eval="0" />
<field name="groups" eval="[(4,ref('project_customer_access.group_customer'))]"/>
</record>

</data>
</odoo>
18 changes: 18 additions & 0 deletions project_customer_access/security/res_groups.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<odoo>

<record id="project_access_category" model="ir.module.category">
<field name="name">Project Access Customer</field>
<field name="sequence">90</field>
</record>
<record id="group_customer" model="res.groups">
<field name="name">Support User</field>
<field name="category_id" ref="project_customer_access.project_access_category"/>
<field name="implied_ids" eval="[(4, ref('base_group_backend.group_backend_ui_users'))]"/>
</record>
<record id="group_manager" model="res.groups">
<field name="name">Support Manager</field>
<field name="category_id" ref="project_customer_access.project_access_category"/>
<field name="implied_ids" eval="[(4, ref('project_customer_access.group_customer'))]"/>
</record>

</odoo>
1 change: 1 addition & 0 deletions project_customer_access/static/description/icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions project_customer_access/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import test_project_customer_access
128 changes: 128 additions & 0 deletions project_customer_access/tests/test_project_customer_access.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading