diff --git a/partner_multi_relation/README.rst b/partner_multi_relation/README.rst index 3ac125d60b7..e57ea98755d 100644 --- a/partner_multi_relation/README.rst +++ b/partner_multi_relation/README.rst @@ -1,7 +1,3 @@ -.. image:: https://odoo-community.org/readme-banner-image - :target: https://odoo-community.org/get-involved?utm_source=readme - :alt: Odoo Community Association - ================= Partner Relations ================= @@ -17,7 +13,7 @@ Partner Relations .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png :target: https://odoo-community.org/page/development-status :alt: Beta -.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html :alt: License: AGPL-3 .. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fpartner--contact-lightgray.png?logo=github diff --git a/partner_multi_relation/__manifest__.py b/partner_multi_relation/__manifest__.py index b2948c45444..c614aadd320 100644 --- a/partner_multi_relation/__manifest__.py +++ b/partner_multi_relation/__manifest__.py @@ -1,8 +1,8 @@ -# Copyright 2013-2022 Therp BV . +# Copyright 2013-2025 Therp BV . # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). { "name": "Partner Relations", - "version": "16.0.1.5.0", + "version": "16.0.2.0.0", "author": "Therp BV,Camptocamp,Odoo Community Association (OCA)", "website": "https://github.com/OCA/partner-contact", "complexity": "normal", @@ -12,11 +12,11 @@ "demo": ["data/demo.xml"], "data": [ "security/ir.model.access.csv", - "views/res_partner_relation_all.xml", - "views/res_partner.xml", "views/res_partner_relation_type.xml", + "views/res_partner_relation.xml", + "views/res_partner.xml", "views/ir_actions_act_window.xml", - "views/ir_ui_menu.xml", + "views/menu.xml", ], "auto_install": False, "installable": True, diff --git a/partner_multi_relation/migrations/16.0.2.0.0/post-migration.py b/partner_multi_relation/migrations/16.0.2.0.0/post-migration.py new file mode 100644 index 00000000000..e8f2509ccaf --- /dev/null +++ b/partner_multi_relation/migrations/16.0.2.0.0/post-migration.py @@ -0,0 +1,14 @@ +# Copyright 2025 Therp BV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import logging + +from openupgradelib import openupgrade + +logger = logging.getLogger(__name__) + + +@openupgrade.migrate() +def migrate(env, version): + logger.info("Delete obsolete SQL views") + env.cr.execute("DROP VIEW IF EXISTS res_partner_relation_all;") + env.cr.execute("DROP VIEW IF EXISTS res_partner_relation_type_selection;") diff --git a/partner_multi_relation/models/__init__.py b/partner_multi_relation/models/__init__.py index ff172efe84a..3fbd7cd577b 100644 --- a/partner_multi_relation/models/__init__.py +++ b/partner_multi_relation/models/__init__.py @@ -1,6 +1,4 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from . import res_partner_relation_type -from . import res_partner_relation_type_selection from . import res_partner_relation -from . import res_partner_relation_all from . import res_partner diff --git a/partner_multi_relation/models/res_partner.py b/partner_multi_relation/models/res_partner.py index c545f05f90a..98d8547717b 100644 --- a/partner_multi_relation/models/res_partner.py +++ b/partner_multi_relation/models/res_partner.py @@ -1,11 +1,21 @@ # Copyright 2013-2025 Therp BV # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). """Support connections between partners.""" -import numbers - -from odoo import _, api, exceptions, fields, models +from odoo import _, api, fields, models from odoo.exceptions import ValidationError -from odoo.osv.expression import FALSE_LEAF, OR, is_leaf +from odoo.osv.expression import AND, FALSE_LEAF, is_leaf + +# Supported operators for _search_relation_ functions. +SUPPORTED_OPERATORS = ( + "=", + "!=", + "like", + "not like", + "ilike", + "not ilike", + "in", + "not in", +) class ResPartner(models.Model): @@ -17,17 +27,21 @@ class ResPartner(models.Model): # pylint: disable=no-member _inherit = "res.partner" - relation_count = fields.Integer(compute="_compute_relation_count") - relation_all_ids = fields.One2many( - comodel_name="res.partner.relation.all", - inverse_name="this_partner_id", - string="All relations with current partner", - auto_join=True, - search=False, + relation_left_ids = fields.One2many( + comodel_name="res.partner.relation", + inverse_name="left_partner_id", + string="Left relations with current partner", copy=False, ) + relation_right_ids = fields.One2many( + comodel_name="res.partner.relation", + inverse_name="right_partner_id", + string="Right relations with current partner", + copy=False, + ) + relation_count = fields.Integer(compute="_compute_relation_count") search_relation_type_id = fields.Many2one( - comodel_name="res.partner.relation.type.selection", + comodel_name="res.partner.relation.type", compute=lambda self: self.update({"search_relation_type_id": None}), search="_search_relation_type_id", string="Has relation of type", @@ -35,7 +49,7 @@ class ResPartner(models.Model): search_relation_partner_id = fields.Many2one( comodel_name="res.partner", compute=lambda self: self.update({"search_relation_partner_id": None}), - search="_search_related_partner_id", + search="_search_relation_partner_id", string="Has relation with", ) search_relation_date = fields.Date( @@ -46,127 +60,133 @@ class ResPartner(models.Model): search_relation_partner_category_id = fields.Many2one( comodel_name="res.partner.category", compute=lambda self: self.update({"search_relation_partner_category_id": None}), - search="_search_related_partner_category_id", + search="_search_relation_partner_category_id", string="Has relation with a partner in category", ) - @api.depends("relation_all_ids") def _compute_relation_count(self): - """Count the number of relations this partner has for Smart Button - - Don't count inactive relations. - """ - for rec in self: - rec.relation_count = len(rec.relation_all_ids.filtered("active")) + """Combined count for left and right partners.""" + for this in self: + this.relation_count = len(this.relation_left_ids.filtered("active")) + len( + this.relation_right_ids.filtered("active") + ) @api.model def _search_relation_type_id(self, operator, value): """Search partners based on their type of relations.""" - result = [] - SUPPORTED_OPERATORS = ( - "=", - "!=", - "like", - "not like", - "ilike", - "not ilike", - "in", - "not in", - ) + self._check_supported_operator(operator) + return [ + "|", + ("relation_left_ids.type_id", operator, value), + ("relation_right_ids.type_id", operator, value), + ] + + @api.model + def _check_supported_operator(self, operator): + """Many search operations only work with comparison operators or (not) in.""" if operator not in SUPPORTED_OPERATORS: - raise exceptions.ValidationError( - _('Unsupported search operator "%s"') % operator - ) - type_selection_model = self.env["res.partner.relation.type.selection"] - relation_type_selection = [] - if operator == "=" and isinstance(value, numbers.Integral): - relation_type_selection += type_selection_model.browse(value) - elif operator == "!=" and isinstance(value, numbers.Integral): - relation_type_selection = type_selection_model.search( - [("id", operator, value)] - ) - else: - relation_type_selection = type_selection_model.search( - [ - "|", - ("type_id.name", operator, value), - ("type_id.name_inverse", operator, value), - ] - ) - if not relation_type_selection: - result = [FALSE_LEAF] - for relation_type in relation_type_selection: - result = OR( - [ - result, - [("relation_all_ids.type_selection_id.id", "=", relation_type.id)], - ] - ) - return result + raise ValidationError(_('Unsupported search operator "%s"', operator)) @api.model - def _search_related_partner_id(self, operator, value): + def _search_relation_partner_id(self, operator, value): """Find partner based on relation with other partner.""" # pylint: disable=no-self-use - return [("relation_all_ids.other_partner_id", operator, value)] + return [ + "|", + ("relation_left_ids.right_partner_id", operator, value), + ("relation_right_ids.left_partner_id", operator, value), + ] @api.model def _search_relation_date(self, operator, value): - """Look only for relations valid at date of search.""" - # pylint: disable=no-self-use - return [ + """Look only for partners that have a relation valid at date of search. + + This makes only sense when combined with other searches on relations. + For instance we want to check for partners that had a relation with + a category of volunteer on 21 february 2022. + + operator is ignored, value must contain a date. + """ + PartnerRelation = self.env["res.partner.relation"] + date_domain = [ "&", "|", - ("relation_all_ids.date_start", "=", False), - ("relation_all_ids.date_start", "<=", value), + ("date_start", "=", False), + ("date_start", "<=", value), + "|", + ("date_end", "=", False), + ("date_end", ">=", value), + ] + left_relations = PartnerRelation.search(date_domain) + right_relations = PartnerRelation.search(date_domain) + if not (left_relations or right_relations): + # Can only happen when there are no valid relations at all... + return [FALSE_LEAF] # pragma: no cover + return [ "|", - ("relation_all_ids.date_end", "=", False), - ("relation_all_ids.date_end", ">=", value), + ("relation_left_ids", "in", left_relations.ids), + ("relation_right_ids", "in", right_relations.ids), ] @api.model - def _search_related_partner_category_id(self, operator, value): + def _search_relation_partner_category_id(self, operator, value): """Search for partner related to a partner with search category.""" # pylint: disable=no-self-use - return [("relation_all_ids.other_partner_id.category_id", operator, value)] + return [ + "|", + ("relation_left_ids.right_partner_id.category_id", operator, value), + ("relation_right_ids.left_partner_id.category_id", operator, value), + ] @api.model - def search(self, args, offset=0, limit=None, order=None, count=False): + def search(self, domain, **kwargs): """Inject searching for current relation date if we search for relation properties and no explicit date was given. """ - # pylint: disable=arguments-differ - # pylint: disable=no-value-for-parameter - date_args = [] - for arg in args: + relation_search = self._get_domain_relation_search(domain) + if relation_search: + # Could be inline, but this is easier for unit test. + domain = self._update_domain_relation_search(domain, relation_search) + return super().search(domain, **kwargs) + + def _get_domain_relation_search(self, domain): + """Check whether domain contains elements that search on relations.""" + relation_search = [] + for part in domain: if ( - is_leaf(arg) - and isinstance(arg[0], str) - and arg[0].startswith("search_relation") + is_leaf(part) + and isinstance(part[0], str) + and part[0].startswith("search_relation") ): - if arg[0] == "search_relation_date": - date_args = [] - break - if not date_args: - date_args = [("search_relation_date", "=", fields.Date.today())] + relation_search.append(part[0]) + return relation_search + + def _update_domain_relation_search(self, domain, relation_search): + """Inject, if needed, date and active criteria in search on relations. + + Need to return new domain if modified, as reassigning will leave + original list argument (domain) unaffected. + """ + if "search_relation_date" not in relation_search: + domain = AND( + [ + domain, + [("search_relation_date", "=", fields.Date.today())], + ] + ) # because of auto_join, we have to do the active test by hand - active_args = [] if self.env.context.get("active_test", True): - for arg in args: - if ( - is_leaf(arg) - and isinstance(arg[0], str) - and arg[0].startswith("search_relation") - ): - active_args = [("relation_all_ids.active", "=", True)] - break - return super().search( - args + date_args + active_args, - offset=offset, - limit=limit, - order=order, - count=count, - ) + domain = AND( + [ + domain, + [ + "|", + ("relation_left_ids.active", "=", True), + ("relation_right_ids.active", "=", True), + ], + ] + ) + return domain def get_partner_type(self): """Get partner type for relation. @@ -200,31 +220,21 @@ def _check_relation_compatibility(self): ) def action_view_relations(self): - for contact in self: - relation_model = self.env["res.partner.relation.all"] - relation_ids = relation_model.search( - [ - "|", - ("this_partner_id", "=", contact.id), - ("other_partner_id", "=", contact.id), - ] - ) - action = self.env["ir.actions.act_window"]._for_xml_id( - "partner_multi_relation.action_res_partner_relation_all" - ) - action["domain"] = [("id", "in", relation_ids.ids)] - context = action.get("context", "{}").strip()[1:-1] - elements = context.split(",") if context else [] - to_add = [ - """'search_default_this_partner_id': {0}, - 'default_this_partner_id': {0}, - 'active_model': 'res.partner', - 'active_id': {0}, - 'active_ids': [{0}], - 'active_test': False""".format( - contact.id - ) - ] - context = "{" + ", ".join(elements + to_add) + "}" - action["context"] = context - return action + self.ensure_one() + return { + "type": "ir.actions.act_window", + "res_model": "res.partner.relation", + "name": _("Connections for current partner"), + "view_mode": "tree,form", + # For the moment default views. + "views": [(False, "list"), (False, "form")], + "domain": [ + "|", + ("left_partner_id", "=", self.id), + ("right_partner_id", "=", self.id), + ], + "context": { + "current_partner_id": self.id, + }, + "target": "top", + } diff --git a/partner_multi_relation/models/res_partner_relation.py b/partner_multi_relation/models/res_partner_relation.py index cb510ba08e9..0853892e8fb 100644 --- a/partner_multi_relation/models/res_partner_relation.py +++ b/partner_multi_relation/models/res_partner_relation.py @@ -1,9 +1,9 @@ -# Copyright 2013-2022 Therp BV +# Copyright 2013-2025 Therp BV # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). -# pylint: disable=api-one-deprecated """Store relations (connections) between partners.""" from odoo import _, api, fields, models from odoo.exceptions import ValidationError +from odoo.osv.expression import AND def record_name(record): @@ -14,17 +14,11 @@ def record_name(record): class ResPartnerRelation(models.Model): - """Model res.partner.relation is used to describe all links or relations - between partners in the database. - - This model is actually only used to store the data. The model - res.partner.relation.all, based on a view that contains each record - two times, once for the normal relation, once for the inverse relation, - will be used to maintain the data. - """ + """Describe the ways partners are related or connected to each other.""" _name = "res.partner.relation" _description = "Partner relation" + _rec_names_search = ["left_partner_id", "type_id", "right_partner_id"] left_partner_id = fields.Many2one( comodel_name="res.partner", @@ -33,6 +27,10 @@ class ResPartnerRelation(models.Model): auto_join=True, ondelete="cascade", ) + left_partner_id_domain = fields.Binary( + compute="_compute_left_partner_id_domain", + default=[], + ) right_partner_id = fields.Many2one( comodel_name="res.partner", string="Destination Partner", @@ -40,14 +38,287 @@ class ResPartnerRelation(models.Model): auto_join=True, ondelete="cascade", ) + right_partner_id_domain = fields.Binary( + compute="_compute_right_partner_id_domain", + default=[], + ) type_id = fields.Many2one( comodel_name="res.partner.relation.type", string="Type", required=True, auto_join=True, ) + type_id_domain = fields.Binary( + compute="_compute_type_id_domain", + default=[], + ) date_start = fields.Date("Starting date") date_end = fields.Date("Ending date") + # TODO: Make cron job to auto-archive records with date_end in the past. + # Think about what happens when date_end cleared or changed. + active = fields.Boolean( + default=True, + readonly=True, + help="Records with date_end in the past should be inactive", + ) + + # == Start of fields that depend on context key current_partner_id. + type_id_display = fields.Char( + compute="_compute_type_id_display", + help="In current context relation is inverse", + ) + this_partner_id = fields.Many2one( + comodel_name="res.partner", + compute="_compute_this_partner_id", + help="Partner shown left when no currently active partner", + ) + other_partner_id = fields.Many2one( + comodel_name="res.partner", + compute="_compute_other_partner_id", + help="Partner shown right when no currently active partner" + ", or connected partnes as seen from current partner.", + ) + # == End of fields that depend on current_partner_id. + + # Start of fields for searching / grouping. + any_partner_id = fields.Many2many( + comodel_name="res.partner", + string="Partner", + compute=lambda self: self.update({"any_partner_id": None}), + search="_search_any_partner_id", + ) + # End of fields for searching / grouping. + + @api.model + def default_get(self, fields_list): + result = super().default_get(fields_list) + if "left_partner_id" in fields_list: + current_partner = self._get_current_partner() + if current_partner: + result["left_partner_id"] = current_partner.id + return result + + @api.onchange("type_id") + def _onchange_type_id(self): + """Unfortunately @api.depends does not work for unsaved changes.""" + self.ensure_one() + self._compute_left_partner_id_domain() + self._compute_right_partner_id_domain() + result = { + "domain": { + "left_partner_id": self.left_partner_id_domain, + "right_partner_id": self.right_partner_id_domain, + } + } + # Check whether domain results in no choice or wrong choice of partners: + warning = ( + self._check_partner_domain( + self.left_partner_id, self.left_partner_id_domain, _("left") + ) + or self._check_partner_domain( + self.right_partner_id, self.right_partner_id_domain, _("right") + ) + or {} + ) + if warning: + result["warning"] = warning + return result + + @api.model + def _check_partner_domain(self, partner, partner_domain, side): + """Check whether partner_domain results in empty selection + for partner, or wrong selection of partner already selected. + """ + test_domain = partner_domain + if partner: + test_domain = AND([partner_domain, [("id", "=", partner.id)]]) + Partner = self.env["res.partner"] + if Partner.search(test_domain, limit=1): + return None + message = ( + _("%s partner incompatible with relation type.", side) + if partner + else _("No %s partner available for relation type.", side) + ) + return { + "title": _("Error!"), + "message": message, + } + + @api.onchange("left_partner_id", "right_partner_id") + def _onchange_partner(self): + """Unfortunately @api.depends does not work for unsaved changes.""" + self.ensure_one() + self._compute_type_id_domain() + result = { + "domain": { + "type_id": self.type_id_domain, + } + } + # Check whether domain results in no choice or wrong choice for type_id. + warning = self._check_type_id_domain() + if warning: + result["warning"] = warning + return result + + def _check_type_id_domain(self): + """If type_id already selected, check whether it + is compatible with the computed type_id_domain. An empty + selection can practically only occur in a practically empty + database, and will not lead to problems. Therefore not tested. + """ + self.ensure_one() + if not self.type_id: + return None + test_domain = AND([self.type_id_domain, [("id", "=", self.type_id.id)]]) + RelationType = self.env["res.partner.relation.type"] + if RelationType.search(test_domain, limit=1): + return None + return { + "title": _("Error!"), + "message": _("Relation type incompatible with selected partner(s)."), + } + + @api.depends("type_id") + def _compute_left_partner_id_domain(self): + """Set domain based mainly on type_id restrictions.""" + for this in self: + domain = [] + if this.type_id: + contact_type = this.type_id.contact_type_left + if contact_type: + is_company = True if contact_type == "c" else False + domain.append(("is_company", "=", is_company)) + category_id = this.type_id.partner_category_left + if category_id: + domain.append(("category_id", "=", category_id.id)) + this.left_partner_id_domain = domain + + @api.depends("type_id") + def _compute_right_partner_id_domain(self): + """Set domain based mainly on type_id restrictions.""" + for this in self: + domain = [] + if this.type_id: + contact_type = this.type_id.contact_type_right + if contact_type: + is_company = True if contact_type == "c" else False + domain.append(("is_company", "=", is_company)) + category_id = this.type_id.partner_category_right + if category_id: + domain.append(("category_id", "=", category_id.id)) + this.right_partner_id_domain = domain + + @api.depends("left_partner_id", "right_partner_id") + def _compute_type_id_domain(self): + """Set domain based on left and right partner.""" + for this in self: + domain = [] + left_partner = this.left_partner_id + if left_partner: + partner_type = "c" if left_partner.is_company else "p" + domain += [ + "|", + ("contact_type_left", "=", False), + ("contact_type_left", "=", partner_type), + "|", + ("partner_category_left", "=", False), + ("partner_category_left", "in", left_partner.category_id.ids), + ] + right_partner = this.right_partner_id + if right_partner: + partner_type = "c" if right_partner.is_company else "p" + domain += [ + "|", + ("contact_type_right", "=", False), + ("contact_type_right", "=", partner_type), + "|", + ("partner_category_right", "=", False), + ("partner_category_right", "in", right_partner.category_id.ids), + ] + this.type_id_domain = domain + + @api.depends( + "left_partner_id.name", + "type_id.name", + "right_partner_id.name", + ) + @api.depends_context("current_partner_id") + def _compute_display_name(self): + """Show inverse names when coming from right partner.""" + current_partner = self._get_current_partner() + for this in self: + if this.right_partner_id and this.right_partner_id == current_partner: + this.display_name = ( + f"{this.right_partner_id.name or ''}" + f" {this.type_id.name_inverse or ''}" + f" {this.left_partner_id.name or ''}" + ) + else: + this.display_name = ( + f"{this.left_partner_id.name or ''}" + f" {this.type_id.name or ''}" + f" {this.right_partner_id.name or ''}" + ) + + @api.depends_context("current_partner_id") + def _compute_type_id_display(self): + """Show inverse type when coming from right partner.""" + current_partner = self._get_current_partner() + for this in self: + if not this.type_id: + this.type_id_display = False + continue + if this.right_partner_id and this.right_partner_id == current_partner: + this.type_id_display = this.type_id.name_inverse + continue + this.type_id_display = this.type_id.name + + @api.depends_context("current_partner_id") + def _compute_this_partner_id(self): + """Show inverse type when coming from right partner.""" + current_partner = self._get_current_partner() + for this in self: + this.this_partner_id = ( + this.right_partner_id + if this.right_partner_id == current_partner + else this.left_partner_id + ) + + @api.depends_context("current_partner_id") + def _compute_other_partner_id(self): + """Show inverse type when coming from right partner.""" + current_partner = self._get_current_partner() + for this in self: + this.other_partner_id = ( + this.left_partner_id + if this.right_partner_id == current_partner + else this.right_partner_id + ) + + @api.model + def _get_current_partner(self): + context = self.env.context + partner_id = ( + context.get("current_partner_id", False) + or ( + context.get("active_model") == "res.partner" + and context.get("active_id", False) + ) + or False + ) + PartnerModel = self.env["res.partner"] + return PartnerModel.browse(partner_id) if partner_id else PartnerModel + + @api.model + def _search_any_partner_id(self, operator, value): + """Search relation with partner, no matter on which side.""" + # pylint: disable=no-self-use + return [ + "|", + ("left_partner_id", operator, value), + ("right_partner_id", operator, value), + ] @api.model_create_multi def create(self, vals_list): @@ -97,9 +368,9 @@ def _check_partner(self, side): :raises ValidationError: When constraint is violated """ for record in self: - assert side in ["left", "right"] - ptype = getattr(record.type_id, "contact_type_%s" % side) - partner = getattr(record, "%s_partner_id" % side) + assert side in ["left", "right"] # pragma no cover + ptype = getattr(record.type_id, f"contact_type_{side}") + partner = getattr(record, f"{side}_partner_id") if (ptype == "c" and not partner.is_company) or ( ptype == "p" and partner.is_company ): @@ -168,3 +439,7 @@ def _check_relation_uniqueness(self): right_partner=record_name(record.right_partner_id), ) ) + + def name_get(self): + """Name will consist of partner names and their connection.""" + return [(this.id, this.display_name) for this in self] diff --git a/partner_multi_relation/models/res_partner_relation_all.py b/partner_multi_relation/models/res_partner_relation_all.py deleted file mode 100644 index 9618728fdcb..00000000000 --- a/partner_multi_relation/models/res_partner_relation_all.py +++ /dev/null @@ -1,497 +0,0 @@ -# Copyright 2014-2022 Therp BV -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). -# pylint: disable=method-required-super -import collections -import logging - -from psycopg2.extensions import AsIs - -from odoo import _, api, fields, models -from odoo.exceptions import MissingError, ValidationError -from odoo.tools import drop_view_if_exists - -_logger = logging.getLogger(__name__) - - -# Register relations -RELATIONS_SQL = """\ -SELECT - (rel.id * %%(padding)s) + %(key_offset)s AS id, - 'res.partner.relation' AS res_model, - rel.id AS res_id, - rel.left_partner_id AS this_partner_id, - rel.right_partner_id AS other_partner_id, - rel.type_id, - rel.date_start, - rel.date_end, - %(is_inverse)s as is_inverse - %(extra_additional_columns)s -FROM res_partner_relation rel""" - -# Register inverse relations -RELATIONS_SQL_INVERSE = """\ -SELECT - (rel.id * %%(padding)s) + %(key_offset)s AS id, - 'res.partner.relation', - rel.id, - rel.right_partner_id, - rel.left_partner_id, - rel.type_id, - rel.date_start, - rel.date_end, - %(is_inverse)s as is_inverse - %(extra_additional_columns)s -FROM res_partner_relation rel""" - - -class ResPartnerRelationAll(models.Model): - """Model to show each relation from two sides.""" - - _auto = False - _log_access = False - _name = "res.partner.relation.all" - _description = "All (non-inverse + inverse) relations between partners" - _order = "this_partner_id, type_selection_id, date_end desc, date_start desc" - _rec_names_search = [ - "this_partner_id.name", - "type_selection_id.name", - "other_partner_id.name", - ] - - res_model = fields.Char( - string="Resource Model", - readonly=True, - required=True, - help="The database object this relation is based on.", - ) - res_id = fields.Integer( - string="Resource ID", - readonly=True, - required=True, - help="The id of the object in the model this relation is based on.", - ) - this_partner_id = fields.Many2one( - comodel_name="res.partner", string="One Partner", required=True - ) - other_partner_id = fields.Many2one(comodel_name="res.partner", required=True) - type_id = fields.Many2one( - comodel_name="res.partner.relation.type", - string="Underlying Relation Type", - readonly=True, - required=True, - ) - date_start = fields.Date("Starting date") - date_end = fields.Date("Ending date") - is_inverse = fields.Boolean( - string="Is reverse type?", - readonly=True, - help="Inverse relations are from right to left partner.", - ) - type_selection_id = fields.Many2one( - comodel_name="res.partner.relation.type.selection", - string="Relation Type", - required=True, - ) - active = fields.Boolean( - readonly=True, - help="Records with date_end in the past are inactive, " - " as well as records for inactive partners.", - ) - any_partner_id = fields.Many2many( - comodel_name="res.partner", - string="Partner", - compute=lambda self: self.update({"any_partner_id": None}), - search="_search_any_partner_id", - ) - - def register_specification(self, register, base_name, is_inverse, select_sql): - _last_key_offset = register["_lastkey"] - key_name = base_name + (is_inverse and "_inverse" or "") - assert key_name not in register - assert "%%(padding)s" in select_sql - assert "%(key_offset)s" in select_sql - assert "%(is_inverse)s" in select_sql - _last_key_offset += 1 - register["_lastkey"] = _last_key_offset - register[key_name] = dict( - base_name=base_name, - is_inverse=is_inverse, - key_offset=_last_key_offset, - select_sql=select_sql - % { - "key_offset": _last_key_offset, - "is_inverse": is_inverse, - "extra_additional_columns": self._get_additional_relation_columns(), - }, - ) - - def get_register(self): - register = collections.OrderedDict() - register["_lastkey"] = -1 - self.register_specification(register, "relation", False, RELATIONS_SQL) - self.register_specification(register, "relation", True, RELATIONS_SQL_INVERSE) - return register - - def get_select_specification(self, base_name, is_inverse): - register = self.get_register() - key_name = base_name + (is_inverse and "_inverse" or "") - return register[key_name] - - def _get_statement(self): - """Allow other modules to add to statement.""" - register = self.get_register() - union_select = " UNION ".join( - [register[key]["select_sql"] for key in register if key != "_lastkey"] - ) - return """\ -CREATE OR REPLACE VIEW %%(table)s AS - WITH base_selection AS (%(union_select)s) - SELECT - bas.*, - CASE - WHEN NOT bas.is_inverse OR typ.is_symmetric - THEN bas.type_id * 2 - ELSE (bas.type_id * 2) + 1 - END as type_selection_id, - ( - (bas.date_end IS NULL OR bas.date_end >= current_date) - AND this_partner.active - AND other_partner.active - ) AS active - %%(additional_view_fields)s - FROM base_selection bas - JOIN res_partner_relation_type typ ON (bas.type_id = typ.id) - JOIN res_partner this_partner ON (bas.this_partner_id = this_partner.id) - JOIN res_partner other_partner ON (bas.other_partner_id = other_partner.id) - %%(additional_tables)s - """ % { - "union_select": union_select - } - - def _get_padding(self): - """Utility function to define padding in one place.""" - return 100 - - def _get_additional_relation_columns(self): - """Get additionnal columns from res_partner_relation. - - This allows to add fields to the model res.partner.relation - and display these fields in the res.partner.relation.all list view. - - :return: ', rel.column_a, rel.column_b_id' - """ - return "" - - def _get_additional_view_fields(self): - """Allow inherit models to add fields to view. - - If fields are added, the resulting string must have each field - prepended by a comma, like so: - return ', typ.allow_self, typ.left_partner_category' - """ - return "" - - def _get_additional_tables(self): - """Allow inherit models to add tables (JOIN's) to view. - - Example: - return 'JOIN type_extention ext ON (bas.type_id = ext.id)' - """ - return "" - - def _auto_init(self): - cr = self._cr - drop_view_if_exists(cr, self._table) - cr.execute( - self._get_statement(), - { - "table": AsIs(self._table), - "padding": self._get_padding(), - "additional_view_fields": AsIs(self._get_additional_view_fields()), - "additional_tables": AsIs(self._get_additional_tables()), - }, - ) - return super(ResPartnerRelationAll, self)._auto_init() - - @api.model - def _search_any_partner_id(self, operator, value): - """Search relation with partner, no matter on which side.""" - # pylint: disable=no-self-use - return [ - "|", - ("this_partner_id", operator, value), - ("other_partner_id", operator, value), - ] - - def name_get(self): - return [ - ( - this.id, - "%s %s %s" - % ( - this.this_partner_id.name, - this.type_selection_id.display_name, - this.other_partner_id.name, - ), - ) - for this in self.with_context(test_active=False) - ] - - @api.onchange("type_selection_id") - def onchange_type_selection_id(self): - """Add domain on partners according to category and contact_type.""" - - def check_partner_domain(partner, partner_domain, side): - """Check whether partner_domain results in empty selection - for partner, or wrong selection of partner already selected. - """ - warning = {} - if partner: - test_domain = [("id", "=", partner.id)] + partner_domain - else: - test_domain = partner_domain - partner_model = self.env["res.partner"] - partners_found = partner_model.search(test_domain, limit=1) - if not partners_found: - warning["title"] = _("Error!") - if partner: - warning["message"] = ( - _("%s partner incompatible with relation type.") % side.title() - ) - else: - warning["message"] = ( - _("No %s partner available for relation type.") % side - ) - return warning - - this_partner_domain = [] - other_partner_domain = [] - if self.type_selection_id.contact_type_this: - this_partner_domain.append( - ("is_company", "=", self.type_selection_id.contact_type_this == "c") - ) - if self.type_selection_id.partner_category_this: - this_partner_domain.append( - ("category_id", "in", self.type_selection_id.partner_category_this.ids) - ) - if self.type_selection_id.contact_type_other: - other_partner_domain.append( - ("is_company", "=", self.type_selection_id.contact_type_other == "c") - ) - if self.type_selection_id.partner_category_other: - other_partner_domain.append( - ("category_id", "in", self.type_selection_id.partner_category_other.ids) - ) - result = { - "domain": { - "this_partner_id": this_partner_domain, - "other_partner_id": other_partner_domain, - } - } - # Check whether domain results in no choice or wrong choice of partners: - warning = {} - partner_model = self.env["res.partner"] - if this_partner_domain: - this_partner = False - if bool(self.this_partner_id.id): - this_partner = self.this_partner_id - else: - this_partner_id = ( - "default_this_partner_id" in self.env.context - and self.env.context["default_this_partner_id"] - or "active_id" in self.env.context - and self.env.context["active_id"] - or False - ) - if this_partner_id: - this_partner = partner_model.browse(this_partner_id) - warning = check_partner_domain(this_partner, this_partner_domain, _("this")) - if not warning and other_partner_domain: - warning = check_partner_domain( - self.other_partner_id, other_partner_domain, _("other") - ) - if warning: - result["warning"] = warning - return result - - @api.onchange("this_partner_id", "other_partner_id") - def onchange_partner_id(self): - """Set domain on type_selection_id based on partner(s) selected.""" - - def check_type_selection_domain(type_selection_domain): - """If type_selection_id already selected, check whether it - is compatible with the computed type_selection_domain. An empty - selection can practically only occur in a practically empty - database, and will not lead to problems. Therefore not tested. - """ - warning = {} - if not (type_selection_domain and self.type_selection_id): - return warning - test_domain = [ - ("id", "=", self.type_selection_id.id) - ] + type_selection_domain - type_model = self.env["res.partner.relation.type.selection"] - types_found = type_model.search(test_domain, limit=1) - if not types_found: - warning["title"] = _("Error!") - warning["message"] = _( - "Relation type incompatible with selected partner(s)." - ) - return warning - - type_selection_domain = [] - if self.this_partner_id: - type_selection_domain += [ - "|", - ("contact_type_this", "=", False), - ("contact_type_this", "=", self.this_partner_id.get_partner_type()), - "|", - ("partner_category_this", "=", False), - ("partner_category_this", "in", self.this_partner_id.category_id.ids), - ] - if self.other_partner_id: - type_selection_domain += [ - "|", - ("contact_type_other", "=", False), - ("contact_type_other", "=", self.other_partner_id.get_partner_type()), - "|", - ("partner_category_other", "=", False), - ("partner_category_other", "in", self.other_partner_id.category_id.ids), - ] - result = {"domain": {"type_selection_id": type_selection_domain}} - # Check whether domain results in no choice or wrong choice for - # type_selection_id: - warning = check_type_selection_domain(type_selection_domain) - if warning: - result["warning"] = warning - return result - - @api.model - def _correct_vals(self, vals, type_selection): - """Fill left and right partner from this and other partner.""" - vals = vals.copy() - if "type_selection_id" in vals: - vals["type_id"] = type_selection.type_id.id - if type_selection.is_inverse: - if "this_partner_id" in vals: - vals["right_partner_id"] = vals["this_partner_id"] - if "other_partner_id" in vals: - vals["left_partner_id"] = vals["other_partner_id"] - else: - if "this_partner_id" in vals: - vals["left_partner_id"] = vals["this_partner_id"] - if "other_partner_id" in vals: - vals["right_partner_id"] = vals["other_partner_id"] - # Delete values not in underlying table: - for key in ( - "this_partner_id", - "type_selection_id", - "other_partner_id", - "is_inverse", - ): - if key in vals: - del vals[key] - return vals - - def get_base_resource(self): - """Get base resource from res_model and res_id.""" - self.ensure_one() - base_model = self.env[self.res_model] - return base_model.browse([self.res_id]) - - def write_resource(self, base_resource, vals): - """write handled by base resource.""" - self.ensure_one() - # write for models other then res.partner.relation SHOULD - # be handled in inherited models: - relation_model = self.env["res.partner.relation"] - assert self.res_model == relation_model._name - base_resource.write(vals) - base_resource.flush_recordset() - - @api.model - def _get_type_selection_from_vals(self, vals): - """Get type_selection_id straight from vals or compute from type_id.""" - type_selection_id = vals.get("type_selection_id", False) - if not type_selection_id: - type_id = vals.get("type_id", False) - if type_id: - is_inverse = vals.get("is_inverse") - type_selection_id = type_id * 2 + (is_inverse and 1 or 0) - return ( - type_selection_id - and self.type_selection_id.browse(type_selection_id) - or False - ) - - def write(self, vals): - """For model 'res.partner.relation' call write on underlying model.""" - new_type_selection = self._get_type_selection_from_vals(vals) - for rec in self: - type_selection = new_type_selection or rec.type_selection_id - vals = rec._correct_vals(vals, type_selection) - base_resource = rec.get_base_resource() - rec.write_resource(base_resource, vals) - # Invalidate cache to make res.partner.relation.all reflect changes - # in underlying res.partner.relation: - self.invalidate_recordset() - return True - - @api.model - def _compute_base_name(self, type_selection): - """This will be overridden for each inherit model.""" - return "relation" - - @api.model - def _compute_id(self, base_resource, type_selection): - """Compute id. Allow for enhancements in inherit model.""" - base_name = self._compute_base_name(type_selection) - key_offset = self.get_select_specification( - base_name, type_selection.is_inverse - )["key_offset"] - return base_resource.id * self._get_padding() + key_offset - - @api.model_create_multi - def create(self, vals_list): - """Divert non-problematic creates to underlying table. - - Create a res.partner.relation but return the converted id. - """ - corrected_vals = [] - type_selections = [] - for vals in vals_list: - type_selection = self._get_type_selection_from_vals(vals) - if not type_selection: # Should not happen - raise ValidationError( - _("No relation type specified in vals: %s.") % vals - ) - corrected_vals.append(self._correct_vals(vals, type_selection)) - type_selections.append(type_selection) - relations = self._create_relations(corrected_vals) - relation_ids = [] - for count, relation in enumerate(relations): - relation_ids.append(self._compute_id(relation, type_selections[count])) - return self.browse(relation_ids) - - def _create_relations(self, vals_list): - relation_model = self.env["res.partner.relation"] - return relation_model.create(vals_list) - - def unlink_resource(self, base_resource): - """Delegate unlink to underlying model.""" - self.ensure_one() - # unlink for models other then res.partner.relation SHOULD - # be handled in inherited models: - relation_model = self.env["res.partner.relation"] - assert self.res_model == relation_model._name - base_resource.unlink() - - def unlink(self): - """For model 'res.partner.relation' call unlink on underlying model.""" - for rec in self: - try: - base_resource = rec.get_base_resource() - except MissingError: - continue - rec.unlink_resource(base_resource) - return True diff --git a/partner_multi_relation/models/res_partner_relation_type.py b/partner_multi_relation/models/res_partner_relation_type.py index 59cecb8deee..5aefe6d709f 100644 --- a/partner_multi_relation/models/res_partner_relation_type.py +++ b/partner_multi_relation/models/res_partner_relation_type.py @@ -1,9 +1,9 @@ -# Copyright 2013-2022 Therp BV . +# Copyright 2013-2025 Therp BV . # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). """Define the type of relations that can exist between partners.""" from odoo import _, api, fields, models from odoo.exceptions import ValidationError -from odoo.osv.expression import AND, OR +from odoo.osv.expression import AND, FALSE_LEAF, OR HANDLE_INVALID_ONCHANGE = [ ("restrict", _("Do not allow change that will result in invalid relations")), @@ -23,6 +23,11 @@ class ResPartnerRelationType(models.Model): name = fields.Char(required=True, translate=True) name_inverse = fields.Char(string="Inverse name", required=True, translate=True) active = fields.Boolean(default=True) + # TODO (on migration to 19.0?): rename fields: + # - contact_type_left => left_partner_type; + # - contact_type_right => right_partner_type; + # - partner_category_left => left_partner_category_id; + # - partner_category_right => right_partner_category_id. contact_type_left = fields.Selection( selection="get_partner_types", string="Left partner type" ) @@ -86,30 +91,29 @@ def _end_active_relations(self, relations): def check_existing(self, vals): """Check whether records exist that do not fit new criteria.""" - relation_model = self.env["res.partner.relation"] + Relation = self.env["res.partner.relation"] def get_type_condition(vals, side): """Add if needed check for contact type.""" - fieldname1 = "contact_type_%s" % side - fieldname2 = "%s_partner_id.is_company" % side - contact_type = fieldname1 in vals and vals[fieldname1] or False - if contact_type == "c": - # Records that are not companies are invalid: - return [(fieldname2, "=", False)] - if contact_type == "p": - # Records that are companies are invalid: - return [(fieldname2, "=", True)] - return [] + fieldname1 = f"contact_type_{side}" + contact_type = vals.get(fieldname1, False) + if not contact_type: + return None + # If contact_type is 'p' company records are invalid. + # If contact_type is 'c' person records are invalid. + is_company = True if contact_type == "p" else False + fieldname2 = f"{side}_partner_id.is_company" + return [(fieldname2, "=", is_company)] def get_category_condition(vals, side): """Add if needed check for partner category.""" - fieldname1 = "partner_category_%s" % side - fieldname2 = "%s_partner_id.category_id" % side - category_id = fieldname1 in vals and vals[fieldname1] or False - if category_id: - # Records that do not have the specified category are invalid: - return [(fieldname2, "not in", [category_id])] - return [] + fieldname1 = f"partner_category_{side}" + category_id = vals.get(fieldname1, False) + if not category_id: + return None + # Records that do not have the specified category are invalid: + fieldname2 = f"{side}_partner_id.category_id" + return [(fieldname2, "!=", category_id)] for this in self: handling = ( @@ -119,19 +123,19 @@ def get_category_condition(vals, side): ) if handling == "ignore": continue - invalid_conditions = [] + invalid_conditions = [FALSE_LEAF] for side in ["left", "right"]: - invalid_conditions = OR( - [invalid_conditions, get_type_condition(vals, side)] - ) - invalid_conditions = OR( - [invalid_conditions, get_category_condition(vals, side)] - ) - if not invalid_conditions: - return + type_condition = get_type_condition(vals, side) + if type_condition: + invalid_conditions = OR([invalid_conditions, type_condition]) + category_condition = get_category_condition(vals, side) + if category_condition: + invalid_conditions = OR([invalid_conditions, category_condition]) + if invalid_conditions == [FALSE_LEAF]: + continue # only look at relations for this type invalid_domain = AND([[("type_id", "=", this.id)], invalid_conditions]) - invalid_relations = relation_model.with_context(active_test=False).search( + invalid_relations = Relation.with_context(active_test=False).search( invalid_domain ) if invalid_relations: @@ -249,11 +253,9 @@ def unlink(self): Relations can be deleted if relation type allows it. """ - relation_model = self.env["res.partner.relation"] - for rec in self: - if rec.handle_invalid_onchange == "delete": - # Automatically delete relations, so existing relations - # do not prevent unlink of relation type: - relations = relation_model.search([("type_id", "=", rec.id)]) - relations.unlink() + delete_enabled = self.filtered(lambda r: r.handle_invalid_onchange == "delete") + if delete_enabled: + Relation = self.env["res.partner.relation"] + to_delete = Relation.search([("type_id", "in", delete_enabled.ids)]) + to_delete.unlink() return super().unlink() diff --git a/partner_multi_relation/models/res_partner_relation_type_selection.py b/partner_multi_relation/models/res_partner_relation_type_selection.py deleted file mode 100644 index acc9de98b0e..00000000000 --- a/partner_multi_relation/models/res_partner_relation_type_selection.py +++ /dev/null @@ -1,147 +0,0 @@ -# Copyright 2013-2022 Therp BV -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). -""" -For the model defined here _auto is set to False to prevent creating a -database file. The model is based on a SQL view based on -res_partner_relation_type where each type is included in the -result set twice, so it appears that the connection type and the inverse -type are separate records.. - -The original function _auto_init is still called because this function -normally (if _auto == True) not only creates the db tables, but it also takes -care of registering all fields in ir_model_fields. This is needed to make -the field labels translatable. -""" -from psycopg2.extensions import AsIs - -from odoo import api, fields, models -from odoo.tools import drop_view_if_exists - - -class ResPartnerRelationTypeSelection(models.Model): - """Virtual relation types""" - - _name = "res.partner.relation.type.selection" - _description = "All relation types" - _auto = False # Do not try to create table in _auto_init(..) - _foreign_keys = [] - _log_access = False - _order = "name asc" - - @api.model - def get_partner_types(self): - """Partner types are defined by model res.partner.relation.type.""" - # pylint: disable=no-self-use - rprt_model = self.env["res.partner.relation.type"] - return rprt_model.get_partner_types() - - type_id = fields.Many2one(comodel_name="res.partner.relation.type") - name = fields.Char(required=True, translate=True) - contact_type_this = fields.Selection( - selection="get_partner_types", string="Current record's partner type" - ) - is_inverse = fields.Boolean( - string="Is reverse type?", - help="Inverse relations are from right to left partner.", - ) - contact_type_other = fields.Selection( - selection="get_partner_types", string="Other record's partner type" - ) - partner_category_this = fields.Many2one( - comodel_name="res.partner.category", string="Current record's category" - ) - partner_category_other = fields.Many2one( - comodel_name="res.partner.category", string="Other record's category" - ) - allow_self = fields.Boolean(string="Reflexive") - is_symmetric = fields.Boolean(string="Symmetric") - - def _get_additional_view_fields(self): - """Allow inherit models to add fields to view. - - If fields are added, the resulting string must have each field - prepended by a comma, like so: - return ', typ.allow_self, typ.left_partner_category' - """ - return "" - - def _get_additional_tables(self): - """Allow inherit models to add tables (JOIN's) to view. - - Example: - return 'JOIN type_extention ext ON (bas.type_id = ext.id)' - """ - return "" - - def _auto_init(self): - cr = self._cr - drop_view_if_exists(cr, self._table) - cr.execute( - """\ -CREATE OR REPLACE VIEW %(table)s AS - WITH selection_type AS ( - SELECT - id * 2 AS id, - id AS type_id, - name AS name, - False AS is_inverse, - contact_type_left AS contact_type_this, - contact_type_right AS contact_type_other, - partner_category_left AS partner_category_this, - partner_category_right AS partner_category_other - FROM %(underlying_table)s - UNION SELECT - (id * 2) + 1, - id, - name_inverse, - True, - contact_type_right, - contact_type_left, - partner_category_right, - partner_category_left - FROM %(underlying_table)s - WHERE not is_symmetric - ) - SELECT - bas.*, - typ.allow_self, - typ.is_symmetric - %(additional_view_fields)s - FROM selection_type bas - JOIN res_partner_relation_type typ ON (bas.type_id = typ.id) - %(additional_tables)s - """, - { - "table": AsIs(self._table), - "underlying_table": AsIs("res_partner_relation_type"), - "additional_view_fields": AsIs(self._get_additional_view_fields()), - "additional_tables": AsIs(self._get_additional_tables()), - }, - ) - return super()._auto_init() - - def name_get(self): - """Get name or name_inverse from underlying model.""" - return [ - ( - this.id, - this.is_inverse - and this.type_id.name_inverse - or this.type_id.display_name, - ) - for this in self - ] - - @api.model - def name_search(self, name="", args=None, operator="ilike", limit=100): - """Search for name or inverse name in underlying model.""" - # pylint: disable=no-value-for-parameter - return self.search( - [ - "|", - ("type_id.name", operator, name), - ("type_id.name_inverse", operator, name), - ] - + (args or []), - limit=limit, - ).name_get() diff --git a/partner_multi_relation/security/ir.model.access.csv b/partner_multi_relation/security/ir.model.access.csv index 3fd6af48fbd..8a086fdf0b0 100644 --- a/partner_multi_relation/security/ir.model.access.csv +++ b/partner_multi_relation/security/ir.model.access.csv @@ -1,8 +1,5 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink -read_res_partner_relation,access_res_partner_relation,model_res_partner_relation,,1,0,0,0 -read_res_partner_relation_all,access_res_partner_relation,model_res_partner_relation_all,,1,0,0,0 -read_res_partner_relation_type,access_res_partner_relation_type,model_res_partner_relation_type,,1,0,0,0 -read_res_partner_relation_type_selection,access_res_partner_relation_type,model_res_partner_relation_type_selection,,1,0,0,0 +read_res_partner_relation,access_res_partner_relation,model_res_partner_relation,base.group_user,1,0,0,0 +read_res_partner_relation_type,access_res_partner_relation_type,model_res_partner_relation_type,base.group_user,1,0,0,0 crud_res_partner_relation,access_res_partner_relation,model_res_partner_relation,base.group_partner_manager,1,1,1,1 -crud_res_partner_relation_all,access_res_partner_relation,model_res_partner_relation_all,base.group_partner_manager,1,1,1,1 crud_res_partner_relation_type,access_res_partner_relation_type,model_res_partner_relation_type,sales_team.group_sale_manager,1,1,1,1 diff --git a/partner_multi_relation/static/description/index.html b/partner_multi_relation/static/description/index.html index dbf4504899b..61d52997dc7 100644 --- a/partner_multi_relation/static/description/index.html +++ b/partner_multi_relation/static/description/index.html @@ -3,16 +3,15 @@ -README.rst +Partner Relations -
+
+

Partner Relations

- - -Odoo Community Association - -
-

Partner Relations

-

Beta License: AGPL-3 OCA/partner-contact Translate me on Weblate Try me on Runboat

+

Beta License: AGPL-3 OCA/partner-contact Translate me on Weblate Try me on Runboat

This module aims to provide generic means to model relations between partners.

Examples would be ‘is sibling of’ or ‘is friend of’, but also ‘has contract X with’ or ‘is assistant of’. This way, you can encode your knowledge about your @@ -403,9 +397,9 @@

Partner Relations

-

Usage

+

Usage

-

Relation Types

+

Relation Types

Before being able to use relations, you’ll have define some first. Do that in Contacts / Relations / Partner relations.

https://raw.githubusercontent.com/OCA/partner-contact/12.0/partner_multi_relation/static/description/relation_type_list.png @@ -415,7 +409,7 @@

Relation Types

https://raw.githubusercontent.com/OCA/partner-contact/12.0/partner_multi_relation/static/description/relation_type_form_name_filled.png
-

Partner Types

+

Partner Types

The Partner Type fields allow to constrain what type of partners can be used on the left and right sides of the relation.

    @@ -429,25 +423,25 @@

    Partner Types

    If you leave these fields empty, the relation is applicable to all types of partners.

-

Partner Categories

+

Partner Categories

You may use categories (tags) to further specify the type of partners.

You could for example enforce the ‘is member of’ relation to accept only companies with the label ‘Organization’ on the right side.

https://raw.githubusercontent.com/OCA/partner-contact/12.0/partner_multi_relation/static/description/relation_type_form_category_filled.png
-

Reflexive

+

Reflexive

A reflexive relation type allows a partner to be in relation with himself.

For example, the CEO of a company could be his own manager.

https://raw.githubusercontent.com/OCA/partner-contact/12.0/partner_multi_relation/static/description/relation_type_reflexive.png
-

Symmetric

+

Symmetric

A symetric relation has the same value for the left and right sides.

For example, in a competitor relation, both companies are competitors of each other.

https://raw.githubusercontent.com/OCA/partner-contact/12.0/partner_multi_relation/static/description/relation_type_symmetric.png
-

Invalid Relation Handling

+

Invalid Relation Handling

When the configuration of a relation type changes, some relations between 2 partners may become invalid.

For example, if the left partner type is set to Person and a relation already exists with a company on the right side, that relation becomes invalid.

@@ -462,7 +456,7 @@

Invalid Relation Handling

-

Searching Partners With Relations

+

Searching Partners With Relations

To search for existing relations, go to Contacts / Relations / Relations.

https://raw.githubusercontent.com/OCA/partner-contact/12.0/partner_multi_relation/static/description/search_relation.png

To find all assistants in your database, fill in ‘assistant’ and @@ -473,14 +467,14 @@

Searching Partners With Relations https://raw.githubusercontent.com/OCA/partner-contact/12.0/partner_multi_relation/static/description/search_relation_3.png

-

Searching Relations From Partner View

+

Searching Relations From Partner View

A smart button is available on the partner form view to display the list of relations.

https://raw.githubusercontent.com/OCA/partner-contact/12.0/partner_multi_relation/static/description/partner_form_view_smart_button.png https://raw.githubusercontent.com/OCA/partner-contact/12.0/partner_multi_relation/static/description/partner_form_view_smart_button_2.png
-

Bug Tracker

+

Bug Tracker

Bugs are tracked on GitHub Issues. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed @@ -488,16 +482,16 @@

Bug Tracker

Do not contact contributors directly about support or help with technical issues.

-

Credits

+

Credits

-

Authors

+

Authors

  • Therp BV
  • Camptocamp
-

Contributors

+

Contributors

-

Maintainers

+

Maintainers

This module is maintained by the OCA.

- -Odoo Community Association - +Odoo Community Association

OCA, or the Odoo Community Association, is a nonprofit organization whose mission is to support the collaborative development of Odoo features and promote its widespread use.

@@ -527,6 +519,5 @@

Maintainers

-
diff --git a/partner_multi_relation/tests/__init__.py b/partner_multi_relation/tests/__init__.py index faf17c27ffc..f0c29a4e696 100644 --- a/partner_multi_relation/tests/__init__.py +++ b/partner_multi_relation/tests/__init__.py @@ -1,7 +1,7 @@ # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). from . import test_partner_constraint from . import test_partner_relation_common -from . import test_partner_relation_action from . import test_partner_relation -from . import test_partner_relation_all +from . import test_partner_relation_action +from . import test_partner_relation_type from . import test_partner_search diff --git a/partner_multi_relation/tests/test_partner_constraint.py b/partner_multi_relation/tests/test_partner_constraint.py index 4d3124434f8..253354b9aa1 100644 --- a/partner_multi_relation/tests/test_partner_constraint.py +++ b/partner_multi_relation/tests/test_partner_constraint.py @@ -5,16 +5,14 @@ from .test_partner_relation_common import TestPartnerRelationCommon -class TestPartnerContraint(TestPartnerRelationCommon): +class TestPartnerConstraint(TestPartnerRelationCommon): def test_change_partner_type(self): - # Create relation between self.partner_02_company and self.partner_01_person - self._create_company2person_relation() with self.assertRaises(ValidationError): self.partner_02_company.write({"is_company": False}) with self.assertRaises(ValidationError): self.partner_01_person.write({"is_company": True}) # Create a relation where the type does not matter. - favorable_type = self.type_model.create( + favorable_type = self.RelationType.create( { "name": "looks favorable on", "name_inverse": "is looked on favorable by", @@ -23,13 +21,13 @@ def test_change_partner_type(self): } ) # Create two persons and connect them. - partner_shoe_shop = self.partner_model.create( + partner_shoe_shop = self.Partner.create( {"name": "Test Jan Shoe Shop", "is_company": False, "ref": "SS01"} ) - partner_maria = self.partner_model.create( + partner_maria = self.Partner.create( {"name": "Maria Montenelli", "is_company": False, "ref": "MM01"} ) - self.relation_model.create( + self.Relation.create( { "left_partner_id": partner_shoe_shop.id, "right_partner_id": partner_maria.id, diff --git a/partner_multi_relation/tests/test_partner_relation.py b/partner_multi_relation/tests/test_partner_relation.py index 52536f2f429..70bb65eaa77 100644 --- a/partner_multi_relation/tests/test_partner_relation.py +++ b/partner_multi_relation/tests/test_partner_relation.py @@ -1,33 +1,352 @@ -# Copyright 2016-2017 Therp BV +# Copyright 2016-2025 Therp BV . # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +import logging from datetime import date, datetime, timedelta -from dateutil.relativedelta import relativedelta - from odoo import fields from odoo.exceptions import ValidationError from .test_partner_relation_common import TestPartnerRelationCommon +_logger = logging.getLogger(__name__) + class TestPartnerRelation(TestPartnerRelationCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + # Create a new relation type which will not have valid relations: + category_nobody = cls.PartnerCategory.create({"name": "Nobody"}) + cls.type_nobody = cls.RelationType.create( + { + "name": "has relation with nobody", + "name_inverse": "nobody has relation with", + "contact_type_left": "c", + "contact_type_right": "p", + "partner_category_left": category_nobody.id, + "partner_category_right": category_nobody.id, + } + ) + + def _get_empty_relation(self): + """Get empty relation record for onchange tests.""" + # Need English, because we will compare text + return self.Relation.with_context(lang="en_US").new({}) + + def test_default_get(self): + """Left partner should be in result if current_partner_id in context.""" + result = self.Relation.with_context( + current_partner_id=self.partner_04_volunteer.id + ).default_get(fields_list=self.Relation._fields) + self.assertIn("left_partner_id", result) + self.assertEqual(result["left_partner_id"], self.partner_04_volunteer.id) + + def test_get_partner_types(self): + """Partner types should contain at least 'c' and 'p'.""" + partner_types = self.RelationType.get_partner_types() + type_codes = [ptype[0] for ptype in partner_types] + self.assertTrue("c" in type_codes) + self.assertTrue("p" in type_codes) + + def test_create_with_active_id(self): + """Test creation with left_partner_id from active_id.""" + # Check whether we can create connection from company to person, + # taking the particular company from the active records: + relation = self.Relation.with_context( + active_id=self.partner_02_company.id, + active_ids=self.partner_02_company.ids, + active_model="res.partner", + ).create( + { + "type_id": self.type_company2person.id, + "right_partner_id": self.partner_04_volunteer.id, + } + ) + self.assertTrue(relation) + self.assertEqual(relation.left_partner_id, self.partner_02_company) + # Partner should have one relation now: + self.assertEqual(self.partner_04_volunteer.relation_count, 1) - post_install = True + def test_name_get(self): + relation = self.company2person_relation + names = relation.name_get() + self.assertEqual(names, [(relation.id, relation.display_name)]) + self.assertEqual(names[0][1], "Test Company has contact Test User 1") - def test_selection_name_search(self): - """Test whether we can find type selection on reverse name.""" - selection_types = self.selection_model.name_search( - name=self.selection_person2company.name + def test_display_name(self): + """Test display name""" + relation = self.company2person_relation + self.assertEqual( + relation.display_name, + f"{relation.left_partner_id.name} {relation.type_id.name} " + f"{relation.right_partner_id.name}", ) - self.assertTrue(selection_types) + + def test_display_name_inverse(self): + """Test display name when coming from right partner.""" + relation = self.company2person_relation.with_context( + current_partner_id=self.partner_01_person.id + ) + self.assertEqual( + relation.display_name, + f"{relation.right_partner_id.name}" + f" {relation.type_id.name_inverse}" + f" {relation.left_partner_id.name}", + ) + + def test_depends_context_current_partner_id_no_context(self): + """Display fields that depend on context, without context.""" + relation = self.company2person_relation + self.assertEqual(relation.this_partner_id, relation.left_partner_id) + self.assertEqual(relation.type_id_display, relation.type_id.name) + self.assertEqual(relation.other_partner_id, relation.right_partner_id) + + def test_depends_context_current_partner_id(self): + """Display fields that depend on context, with context.""" + relation = self.company2person_relation.with_context( + current_partner_id=self.partner_01_person.id + ) + self.assertEqual(relation.this_partner_id, relation.right_partner_id) + self.assertEqual(relation.type_id_display, relation.type_id.name_inverse) + self.assertEqual(relation.other_partner_id, relation.left_partner_id) + + def test_validate_contact_type(self): + """Create with wrong partner for type should raise ValidationError.""" + with self.assertRaises(ValidationError): + self.Relation.create( + { + # Left partner should be a company, but is not. + "left_partner_id": self.partner_04_volunteer.id, + "type_id": self.type_company2person.id, + "right_partner_id": self.partner_01_person.id, + } + ) + + def test_validate_contact_category(self): + """Create with partner with missing category should raise ValidationError.""" + with self.assertRaises(ValidationError): + self.Relation.create( + { + "left_partner_id": self.partner_03_ngo.id, + "type_id": self.type_ngo2volunteer.id, + # Right partner does not have volunteer category. + "right_partner_id": self.partner_01_person.id, + } + ) + + def test_write_incompatible_dates(self): + """Test write with date_end before date_start.""" + relation = self.company2person_relation + with self.assertRaises(ValidationError): + relation.write({"date_start": "2016-09-01", "date_end": "2016-08-01"}) + + def test_validate_overlapping_01(self): + """Test create overlapping with no start / end dates.""" + relation = self.company2person_relation + with self.assertRaises(ValidationError): + # New relation with no start / end should give error + self.Relation.create( + { + "left_partner_id": relation.left_partner_id.id, + "type_id": relation.type_id.id, + "right_partner_id": relation.right_partner_id.id, + } + ) + + def test_validate_overlapping_02(self): + """Test create overlapping with start / end dates.""" + relation = self.company2person_relation + # New relation with overlapping start / end should give error + with self.assertRaises(ValidationError): + self.Relation.create( + { + "left_partner_id": relation.left_partner_id.id, + "type_id": relation.type_id.id, + "right_partner_id": relation.right_partner_id.id, + "date_start": "2016-08-01", + "date_end": "2017-07-30", + } + ) + + def test_validate_overlapping_03(self): + """Test create not overlapping.""" + relation = self.company2person_relation + relation.write( + { + "date_start": "2015-09-01", + "date_end": "2016-08-31", + } + ) + relation_another_record = self.Relation.create( + { + "left_partner_id": relation.left_partner_id.id, + "type_id": relation.type_id.id, + "right_partner_id": relation.right_partner_id.id, + "date_start": "2016-09-01", + "date_end": "2017-08-31", + } + ) + self.assertTrue(relation_another_record) + + def test_onchange_type_id_empty_relation(self): + """Test on_change_type_id with empty relation.""" + relation_empty = self._get_empty_relation() + result = relation_empty._onchange_type_id() + domain = self._get_domain_from_logged_result(result) + self.assertFalse("warning" in result) + self.assertTrue("left_partner_id" in domain) + self.assertEqual(domain["left_partner_id"], []) + self.assertTrue("right_partner_id" in domain) + self.assertEqual(domain["right_partner_id"], []) + + def test_empty_type_id(self): + """Test type_id_display empty if type_id empty.""" + relation_empty = self._get_empty_relation() + relation_empty.update( + { + "left_partner_id": self.partner_03_ngo.id, + "right_partner_id": self.partner_01_person.id, + } + ) + self.assertFalse(relation_empty.type_id_display) + + def _get_domain_from_logged_result(self, result): + """Get domain from result, logging it for easy debugging.""" + _logger.info("result: %s", str(result)) + self.assertTrue("domain" in result) + return result["domain"] + + def test_onchange_type_id_no_criteria(self): + """Test on_change_type_id for type with no criteria.""" + type_ngo_volunteer = self.RelationType.create( + { + "name": "ngo has volunteer", + "name_inverse": "volunteer works for ngo", + "handle_invalid_onchange": "restrict", + } + ) + relation = self.Relation.create( + { + "left_partner_id": self.partner_03_ngo.id, + "type_id": type_ngo_volunteer.id, + "right_partner_id": self.partner_04_volunteer.id, + } + ) + result = relation._onchange_type_id() + domain = self._get_domain_from_logged_result(result) + self.assertEqual(domain["left_partner_id"], []) + self.assertEqual(domain["right_partner_id"], []) + + def test_onchange_type_id_company2person(self): + """Test on_change_type_id with company 2 person relation.""" + relation = self.company2person_relation + result = relation._onchange_type_id() + domain = self._get_domain_from_logged_result(result) + self.assertTrue(("is_company", "=", True) in domain["left_partner_id"]) + self.assertTrue(("is_company", "=", False) in domain["right_partner_id"]) + + def test_onchange_type_id_needing_categories(self): + """Test on_change_type_id with relation needing categories.""" + # Take left partner from active_id. + relation_ngo_volunteer = self.Relation.with_context( + active_id=self.partner_03_ngo.id + ).create( + { + "type_id": self.type_ngo2volunteer.id, + "right_partner_id": self.partner_04_volunteer.id, + } + ) + result = relation_ngo_volunteer._onchange_type_id() + domain = self._get_domain_from_logged_result(result) self.assertTrue( - (self.selection_person2company.id, self.selection_person2company.name) - in selection_types + ("category_id", "=", self.category_01_ngo.id) in domain["left_partner_id"] ) + self.assertTrue( + ("category_id", "=", self.category_02_volunteer.id) + in domain["right_partner_id"] + ) + + def test_search_any_partner(self): + """Test searching for partner left or right.""" + relation = self.Relation.search( + [("any_partner_id", "=", self.partner_02_company.id)] + ) + self.assertEqual(relation.left_partner_id, self.partner_02_company) + relation = self.Relation.search([("any_partner_id", "like", "User")]) + self.assertEqual(relation.left_partner_id, self.partner_02_company) + self.assertEqual(relation.right_partner_id, self.partner_01_person) + + def test_onchange_type_id_impossible_combinations(self): + """Test on_change_type_id with invalid or impossible combinations.""" + relation_nobody = self._get_empty_relation() + relation_nobody.type_id = self.type_nobody + warning = relation_nobody._onchange_type_id()["warning"] + self.assertTrue("message" in warning) + self.assertTrue("No left partner available" in warning["message"]) + relation_nobody.left_partner_id = self.partner_02_company + warning = relation_nobody._onchange_type_id()["warning"] + self.assertTrue("message" in warning) + self.assertTrue("incompatible" in warning["message"]) + # Allow left partner and check message for other partner: + self.type_nobody.write({"partner_category_left": False}) + warning = relation_nobody._onchange_type_id()["warning"] + self.assertTrue("message" in warning) + self.assertTrue("No right partner available" in warning["message"]) + + def test_onchange_partner(self): + """Test on_change_partner_id.""" + # 1. Test call with empty relation + relation_empty = self._get_empty_relation() + result = relation_empty._onchange_partner() + self.assertTrue("domain" in result) + self.assertFalse("warning" in result) + self.assertTrue("type_id" in result["domain"]) + self.assertFalse(result["domain"]["type_id"]) + # 2. Test call with company 2 person relation + relation = self.company2person_relation + domain = relation._onchange_partner()["domain"] + self.assertTrue(("contact_type_left", "=", "c") in domain["type_id"]) + # 3. Test with invalid or impossible combinations + relation_nobody = self._get_empty_relation() + relation_nobody.left_partner_id = self.partner_02_company + relation_nobody.type_id = self.type_nobody + warning = relation_nobody._onchange_partner()["warning"] + self.assertTrue("message" in warning) + self.assertTrue("incompatible" in warning["message"]) + + def test_write(self): + """Test write. Special attention for changing type.""" + relation = self.company2person_relation + company_partner = relation.left_partner_id + # First get another worker: + partner_extra_person = self.Partner.create( + {"name": "A new worker", "is_company": False, "ref": "NW01"} + ) + relation.write({"right_partner_id": partner_extra_person.id}) + self.assertEqual(relation.right_partner_id.name, partner_extra_person.name) + # We will also change to a type going from person to company: + type_worker2company = self.RelationType.create( + { + "name": "works for", + "name_inverse": "has worker", + "contact_type_left": "p", + "contact_type_right": "c", + } + ) + relation.write( + { + "left_partner_id": partner_extra_person.id, + "type_id": type_worker2company.id, + "right_partner_id": company_partner.id, + } + ) + self.assertEqual(relation.left_partner_id.id, partner_extra_person.id) + self.assertEqual(relation.type_id.id, type_worker2company.id) + self.assertEqual(relation.right_partner_id.id, company_partner.id) def test_self_allowed(self): """Test creation of relation to same partner when type allows.""" - type_allow = self.type_model.create( + type_allow = self.RelationType.create( { "name": "allow", "name_inverse": "allow_inverse", @@ -37,7 +356,7 @@ def test_self_allowed(self): } ) self.assertTrue(type_allow) - reflexive_relation = self.relation_model.create( + reflexive_relation = self.Relation.create( { "type_id": type_allow.id, "left_partner_id": self.partner_01_person.id, @@ -52,7 +371,7 @@ def test_self_disallowed(self): Attempt to create a relation of a partner to the same partner should raise an error when the type of relation explicitly disallows this. """ - type_disallow = self.type_model.create( + type_disallow = self.RelationType.create( { "name": "disallow", "name_inverse": "disallow_inverse", @@ -63,7 +382,7 @@ def test_self_disallowed(self): ) self.assertTrue(type_disallow) with self.assertRaises(ValidationError): - self.relation_model.create( + self.Relation.create( { "type_id": type_disallow.id, "left_partner_id": self.partner_01_person.id, @@ -77,7 +396,7 @@ def test_self_disallowed_after_self_relation_created(self): If at least one reflexive relation exists for the given type, reflexivity can not be disallowed. """ - type_allow = self.type_model.create( + type_allow = self.RelationType.create( { "name": "allow", "name_inverse": "allow_inverse", @@ -87,7 +406,7 @@ def test_self_disallowed_after_self_relation_created(self): } ) self.assertTrue(type_allow) - reflexive_relation = self.relation_model.create( + reflexive_relation = self.Relation.create( { "type_id": type_allow.id, "left_partner_id": self.partner_01_person.id, @@ -97,6 +416,9 @@ def test_self_disallowed_after_self_relation_created(self): self.assertTrue(reflexive_relation) with self.assertRaises(ValidationError): type_allow.allow_self = False + # If we remove the reflexive relation, we should be able to change. + reflexive_relation.unlink() + type_allow.allow_self = False def test_self_disallowed_with_delete_invalid_relations(self): """Test handle_invalid_onchange delete with allow_self disabled. @@ -106,7 +428,7 @@ def test_self_disallowed_with_delete_invalid_relations(self): Non reflexive relations are not modified. """ - type_allow = self.type_model.create( + type_allow = self.RelationType.create( { "name": "allow", "name_inverse": "allow_inverse", @@ -116,21 +438,20 @@ def test_self_disallowed_with_delete_invalid_relations(self): "handle_invalid_onchange": "delete", } ) - reflexive_relation = self.relation_model.create( + reflexive_relation = self.Relation.create( { "type_id": type_allow.id, "left_partner_id": self.partner_01_person.id, "right_partner_id": self.partner_01_person.id, } ) - normal_relation = self.relation_model.create( + normal_relation = self.Relation.create( { "type_id": type_allow.id, "left_partner_id": self.partner_01_person.id, "right_partner_id": self.partner_04_volunteer.id, } ) - type_allow.allow_self = False self.assertFalse(reflexive_relation.exists()) self.assertTrue(normal_relation.exists()) @@ -146,7 +467,7 @@ def test_self_disallowed_with_end_invalid_relations(self): Reflexive relations with an end date prior to the current date are not modified. """ - type_allow = self.type_model.create( + type_allow = self.RelationType.create( { "name": "allow", "name_inverse": "allow_inverse", @@ -156,7 +477,7 @@ def test_self_disallowed_with_end_invalid_relations(self): "handle_invalid_onchange": "end", } ) - reflexive_relation = self.relation_model.create( + reflexive_relation = self.Relation.create( { "type_id": type_allow.id, "left_partner_id": self.partner_01_person.id, @@ -164,7 +485,7 @@ def test_self_disallowed_with_end_invalid_relations(self): "date_start": "2000-01-02", } ) - past_reflexive_relation = self.relation_model.create( + past_reflexive_relation = self.Relation.create( { "type_id": type_allow.id, "left_partner_id": self.partner_01_person.id, @@ -172,14 +493,13 @@ def test_self_disallowed_with_end_invalid_relations(self): "date_end": "2000-01-01", } ) - normal_relation = self.relation_model.create( + normal_relation = self.Relation.create( { "type_id": type_allow.id, "left_partner_id": self.partner_01_person.id, "right_partner_id": self.partner_04_volunteer.id, } ) - type_allow.allow_self = False self.assertEqual(reflexive_relation.date_end, fields.Date.today()) self.assertEqual(past_reflexive_relation.date_end, date(2000, 1, 1)) @@ -191,7 +511,7 @@ def test_self_disallowed_with_future_reflexive_relation(self): If handle_invalid_onchange is set to end, then deactivating reflexivity will delete invalid relations in the future. """ - type_allow = self.type_model.create( + type_allow = self.RelationType.create( { "name": "allow", "name_inverse": "allow_inverse", @@ -201,7 +521,7 @@ def test_self_disallowed_with_future_reflexive_relation(self): "handle_invalid_onchange": "end", } ) - future_reflexive_relation = self.relation_model.create( + future_reflexive_relation = self.Relation.create( { "type_id": type_allow.id, "left_partner_id": self.partner_01_person.id, @@ -219,7 +539,7 @@ def test_self_default(self): raise an error when the type of relation does not explicitly allow this. """ - type_default = self.type_model.create( + type_default = self.RelationType.create( { "name": "default", "name_inverse": "default_inverse", @@ -229,229 +549,10 @@ def test_self_default(self): ) self.assertTrue(type_default) with self.assertRaises(ValidationError): - self.relation_model.create( + self.Relation.create( { "type_id": type_default.id, "left_partner_id": self.partner_01_person.id, "right_partner_id": self.partner_01_person.id, } ) - - def test_self_mixed(self): - """Test creation of relation with wrong types. - - Trying to create a relation between partners with an inappropiate - type should raise an error. - """ - with self.assertRaises(ValidationError): - self.relation_model.create( - { - "type_id": self.type_company2person.id, - "left_partner_id": self.partner_01_person.id, - "right_partner_id": self.partner_02_company.id, - } - ) - - def test_symmetric(self): - """Test creating symmetric relation.""" - # Start out with non symmetric relation: - type_symmetric = self.type_model.create( - { - "name": "not yet symmetric", - "name_inverse": "the other side of not symmetric", - "is_symmetric": False, - "contact_type_left": False, - "contact_type_right": "p", - } - ) - # not yet symmetric relation should result in two records in - # selection: - selection_symmetric = self.selection_model.search( - [("type_id", "=", type_symmetric.id)] - ) - self.assertEqual(len(selection_symmetric), 2) - # Now change to symmetric and test name and inverse name: - type_symmetric.write({"name": "sym", "is_symmetric": True}) - self.assertEqual(type_symmetric.is_symmetric, True) - self.assertEqual(type_symmetric.name_inverse, type_symmetric.name) - self.assertEqual( - type_symmetric.contact_type_right, type_symmetric.contact_type_left - ) - # now update the database: - type_symmetric.write( - { - "name": type_symmetric.name, - "is_symmetric": type_symmetric.is_symmetric, - "name_inverse": type_symmetric.name_inverse, - "contact_type_right": type_symmetric.contact_type_right, - } - ) - type_symmetric.flush_recordset() - # symmetric relation should result in only one record in - # selection: - selection_symmetric = self.selection_model.search( - [("type_id", "=", type_symmetric.id)] - ) - self.assertEqual(len(selection_symmetric), 1) - relation = self.relation_all_model.create( - { - "type_selection_id": selection_symmetric.id, - "this_partner_id": self.partner_02_company.id, - "other_partner_id": self.partner_01_person.id, - } - ) - partners = self.partner_model.search( - [("search_relation_type_id", "=", relation.type_selection_id.id)] - ) - self.assertTrue(self.partner_01_person in partners) - self.assertTrue(self.partner_02_company in partners) - - def test_category_domain(self): - """Test check on category in relations.""" - # Check on left side: - with self.assertRaises(ValidationError): - self.relation_model.create( - { - "type_id": self.type_ngo2volunteer.id, - "left_partner_id": self.partner_02_company.id, - "right_partner_id": self.partner_04_volunteer.id, - } - ) - # Check on right side: - with self.assertRaises(ValidationError): - self.relation_model.create( - { - "type_id": self.type_ngo2volunteer.id, - "left_partner_id": self.partner_03_ngo.id, - "right_partner_id": self.partner_01_person.id, - } - ) - - def test_relation_type_change(self): - """Test change in relation type conditions.""" - # First create a relation type having no particular conditions. - ( - type_school2student, - school2student, - school2student_inverse, - ) = self._create_relation_type_selection( - {"name": "school has student", "name_inverse": "studies at school"} - ) - # Second create relations based on those conditions. - partner_school = self.partner_model.create( - {"name": "Test School", "is_company": True, "ref": "TS"} - ) - partner_bart = self.partner_model.create( - {"name": "Bart Simpson", "is_company": False, "ref": "BS"} - ) - partner_lisa = self.partner_model.create( - {"name": "Lisa Simpson", "is_company": False, "ref": "LS"} - ) - relation_school2bart = self.relation_all_model.create( - { - "this_partner_id": partner_school.id, - "type_selection_id": school2student.id, - "other_partner_id": partner_bart.id, - } - ) - self.assertTrue(relation_school2bart) - relation_school2lisa = self.relation_all_model.create( - { - "this_partner_id": partner_school.id, - "type_selection_id": school2student.id, - "other_partner_id": partner_lisa.id, - } - ) - self.assertTrue(relation_school2lisa) - relation_bart2lisa = self.relation_all_model.create( - { - "this_partner_id": partner_bart.id, - "type_selection_id": school2student.id, - "other_partner_id": partner_lisa.id, - } - ) - self.assertTrue(relation_bart2lisa) - # Third creata a category and make it a condition for the - # relation type. - # - Test restriction - # - Test ignore - category_student = self.category_model.create({"name": "Student"}) - with self.assertRaises(ValidationError): - type_school2student.write({"partner_category_right": category_student.id}) - self.assertFalse(type_school2student.partner_category_right.id) - type_school2student.write( - { - "handle_invalid_onchange": "ignore", - "partner_category_right": category_student.id, - } - ) - self.assertEqual( - type_school2student.partner_category_right.id, category_student.id - ) - # Fourth make company type a condition for left partner - # - Test ending - # - Test deletion - partner_bart.write({"category_id": [(4, category_student.id)]}) - partner_lisa.write({"category_id": [(4, category_student.id)]}) - # Future student to be deleted by end action: - partner_homer = self.partner_model.create( - { - "name": "Homer Simpson", - "is_company": False, - "ref": "HS", - "category_id": [(4, category_student.id)], - } - ) - relation_lisa2homer = self.relation_all_model.create( - { - "this_partner_id": partner_lisa.id, - "type_selection_id": school2student.id, - "other_partner_id": partner_homer.id, - "date_start": date.today() + relativedelta(months=+6), - } - ) - self.assertTrue(relation_lisa2homer) - type_school2student.write( - {"handle_invalid_onchange": "end", "contact_type_left": "c"} - ) - self.assertEqual(relation_bart2lisa.date_end, fields.Date.today()) - self.assertFalse(relation_lisa2homer.exists()) - type_school2student.write( - { - "handle_invalid_onchange": "delete", - "contact_type_left": "c", - "contact_type_right": "p", - } - ) - self.assertFalse(relation_bart2lisa.exists()) - - def test_relation_type_unlink(self): - """Test delete of relation type, including deleting relations.""" - # First create a relation type having restrict particular conditions. - type_model = self.env["res.partner.relation.type"] - relation_model = self.env["res.partner.relation"] - partner_model = self.env["res.partner"] - type_school2student = type_model.create( - { - "name": "school has student", - "name_inverse": "studies at school", - "handle_invalid_onchange": "delete", - } - ) - # Second create relation based on those conditions. - partner_school = partner_model.create( - {"name": "Test School", "is_company": True, "ref": "TS"} - ) - partner_bart = partner_model.create( - {"name": "Bart Simpson", "is_company": False, "ref": "BS"} - ) - relation_school2bart = relation_model.create( - { - "left_partner_id": partner_school.id, - "type_id": type_school2student.id, - "right_partner_id": partner_bart.id, - } - ) - # Delete type. Relations with type should also cease to exist: - type_school2student.unlink() - self.assertFalse(relation_school2bart.exists()) diff --git a/partner_multi_relation/tests/test_partner_relation_action.py b/partner_multi_relation/tests/test_partner_relation_action.py index d8939c8aee1..65f63c1c40b 100644 --- a/partner_multi_relation/tests/test_partner_relation_action.py +++ b/partner_multi_relation/tests/test_partner_relation_action.py @@ -1,29 +1,26 @@ -# © 2021 Tobias Zehntner -# © 2021 Niboo SRL (https://www.niboo.com/) +# Copyright 2021 Tobias Zehntner. +# Copyright 2021 Niboo SRL . +# Copyright 2025 Therp BV . # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). -from odoo import exceptions - from .test_partner_relation_common import TestPartnerRelationCommon class TestPartnerRelationAction(TestPartnerRelationCommon): - def setUp(self): - super().setUp() - self.user = self.env["res.users"].create( + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.user = cls.env["res.users"].create( { "login": "test_partner_action_user", "name": "test_partner_action_user", "groups_id": [ - (4, self.env.ref("base.group_user").id), + (4, cls.env.ref("base.group_user").id), ], } ) def test_call_relation_action(self): """Test calling relations action. Should be possible with simple user rights""" - try: - self.partner_01_person.with_user(self.user).action_view_relations() - except exceptions.AccessError: - self.fail("action_view_relations() raised AccessError unexpectedly!") + self.partner_01_person.with_user(self.user).action_view_relations() diff --git a/partner_multi_relation/tests/test_partner_relation_all.py b/partner_multi_relation/tests/test_partner_relation_all.py deleted file mode 100644 index 1740a3d1bba..00000000000 --- a/partner_multi_relation/tests/test_partner_relation_all.py +++ /dev/null @@ -1,333 +0,0 @@ -# Copyright 2016-2017 Therp BV -# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). - -from datetime import date - -from odoo.exceptions import ValidationError - -from .test_partner_relation_common import TestPartnerRelationCommon - - -class TestPartnerRelation(TestPartnerRelationCommon): - def setUp(self): - super(TestPartnerRelation, self).setUp() - - # Create a new relation type which will not have valid relations: - category_nobody = self.category_model.create({"name": "Nobody"}) - ( - self.type_nobody, - self.selection_nobody, - self.selection_nobody_inverse, - ) = self._create_relation_type_selection( - { - "name": "has relation with nobody", - "name_inverse": "nobody has relation with", - "contact_type_left": "c", - "contact_type_right": "p", - "partner_category_left": category_nobody.id, - "partner_category_right": category_nobody.id, - } - ) - - def _get_empty_relation(self): - """Get empty relation record for onchange tests.""" - # Need English, because we will compare text - return self.relation_all_model.with_context(lang="en_US").new({}) - - def test_get_partner_types(self): - """Partner types should contain at least 'c' and 'p'.""" - partner_types = self.selection_model.get_partner_types() - type_codes = [ptype[0] for ptype in partner_types] - self.assertTrue("c" in type_codes) - self.assertTrue("p" in type_codes) - - def test_create_with_active_id(self): - """Test creation with this_partner_id from active_id.""" - # Check whether we can create connection from company to person, - # taking the particular company from the active records: - relation = self.relation_all_model.with_context( - active_id=self.partner_02_company.id, active_ids=self.partner_02_company.ids - ).create( - { - "other_partner_id": self.partner_01_person.id, - "type_selection_id": self.selection_company2person.id, - } - ) - self.assertTrue(relation) - self.assertEqual(relation.this_partner_id, self.partner_02_company) - # Partner should have one relation now: - relation.invalidate_recordset() - self.partner_01_person.flush_recordset() - self.assertEqual(self.partner_01_person.relation_count, 1) - # Test create without type_selection_id: - with self.assertRaises(ValidationError): - self.relation_all_model.create( - { - "this_partner_id": self.partner_02_company.id, - "other_partner_id": self.partner_01_person.id, - } - ) - - def test_display_name(self): - """Test display name""" - relation = self._create_company2person_relation() - self.assertEqual( - relation.display_name, - "%s %s %s" - % ( - relation.this_partner_id.name, - relation.type_selection_id.name, - relation.other_partner_id.name, - ), - ) - - def test_regular_write(self): - """Test write with valid data.""" - relation = self._create_company2person_relation() - relation.write({"date_start": "2014-09-01"}) - self.assertEqual(relation.date_start, date(2014, 9, 1)) - - def test_write_incompatible_dates(self): - """Test write with date_end before date_start.""" - relation = self._create_company2person_relation() - with self.assertRaises(ValidationError): - relation.write({"date_start": "2016-09-01", "date_end": "2016-08-01"}) - - def test_validate_overlapping_01(self): - """Test create overlapping with no start / end dates.""" - relation = self._create_company2person_relation() - with self.assertRaises(ValidationError): - # New relation with no start / end should give error - self.relation_all_model.create( - { - "this_partner_id": relation.this_partner_id.id, - "type_selection_id": relation.type_selection_id.id, - "other_partner_id": relation.other_partner_id.id, - } - ) - - def test_validate_overlapping_02(self): - """Test create overlapping with start / end dates.""" - relation = self.relation_all_model.create( - { - "this_partner_id": self.partner_02_company.id, - "type_selection_id": self.selection_company2person.id, - "other_partner_id": self.partner_01_person.id, - "date_start": "2015-09-01", - "date_end": "2016-08-31", - } - ) - # New relation with overlapping start / end should give error - with self.assertRaises(ValidationError): - self.relation_all_model.create( - { - "this_partner_id": relation.this_partner_id.id, - "type_selection_id": relation.type_selection_id.id, - "other_partner_id": relation.other_partner_id.id, - "date_start": "2016-08-01", - "date_end": "2017-07-30", - } - ) - - def test_validate_overlapping_03(self): - """Test create not overlapping.""" - relation = self.relation_all_model.create( - { - "this_partner_id": self.partner_02_company.id, - "type_selection_id": self.selection_company2person.id, - "other_partner_id": self.partner_01_person.id, - "date_start": "2015-09-01", - "date_end": "2016-08-31", - } - ) - relation_another_record = self.relation_all_model.create( - { - "this_partner_id": relation.this_partner_id.id, - "type_selection_id": relation.type_selection_id.id, - "other_partner_id": relation.other_partner_id.id, - "date_start": "2016-09-01", - "date_end": "2017-08-31", - } - ) - self.assertTrue(relation_another_record) - - def test_inverse_record(self): - """Test creation of inverse record.""" - relation = self._create_company2person_relation() - inverse_relation = self.relation_all_model.search( - [ - ("this_partner_id", "=", relation.other_partner_id.id), - ("other_partner_id", "=", relation.this_partner_id.id), - ] - ) - self.assertEqual(len(inverse_relation), 1) - self.assertEqual( - inverse_relation.type_selection_id.name, self.selection_person2company.name - ) - - def test_inverse_creation(self): - """Test creation of record through inverse selection.""" - relation = self.relation_all_model.create( - { - "this_partner_id": self.partner_01_person.id, - "type_selection_id": self.selection_person2company.id, - "other_partner_id": self.partner_02_company.id, - } - ) - # Check whether display name is what we should expect: - self.assertEqual( - relation.display_name, - "%s %s %s" - % ( - self.partner_01_person.name, - self.selection_person2company.name, - self.partner_02_company.name, - ), - ) - - def test_inverse_creation_type_id(self): - """Test creation of record through inverse selection with type_id.""" - relation = self.relation_all_model.create( - { - "this_partner_id": self.partner_01_person.id, - "type_id": self.selection_person2company.type_id.id, - "is_inverse": True, - "other_partner_id": self.partner_02_company.id, - } - ) - # Check whether display name is what we should expect: - self.assertEqual( - relation.display_name, - "%s %s %s" - % ( - self.partner_01_person.name, - self.selection_person2company.name, - self.partner_02_company.name, - ), - ) - - def test_unlink(self): - """Unlinking derived relation should unlink base relation.""" - # Check whether underlying record is removed when record is removed: - relation = self._create_company2person_relation() - base_model = self.env[relation.res_model] - base_relation = base_model.browse([relation.res_id]) - relation.unlink() - self.assertFalse(base_relation.exists()) - # Check unlinking record sets with both derived relation records - self.assertTrue(self.relation_all_model.search([]).unlink()) - - def test_on_change_type_selection(self): - """Test on_change_type_selection.""" - # 1. Test call with empty relation - relation_empty = self._get_empty_relation() - result = relation_empty.onchange_type_selection_id() - self.assertTrue("domain" in result) - self.assertFalse("warning" in result) - self.assertTrue("this_partner_id" in result["domain"]) - self.assertFalse(result["domain"]["this_partner_id"]) - self.assertTrue("other_partner_id" in result["domain"]) - self.assertFalse(result["domain"]["other_partner_id"]) - # 2. Test call with company 2 person relation - relation = self._create_company2person_relation() - domain = relation.onchange_type_selection_id()["domain"] - self.assertTrue(("is_company", "=", False) in domain["other_partner_id"]) - # 3. Test with relation needing categories, - # take active partner from active_id: - relation_ngo_volunteer = self.relation_all_model.with_context( - active_id=self.partner_03_ngo.id - ).create( - { - "type_selection_id": self.selection_ngo2volunteer.id, - "other_partner_id": self.partner_04_volunteer.id, - } - ) - domain = relation_ngo_volunteer.onchange_type_selection_id()["domain"] - self.assertTrue( - ("category_id", "in", [self.category_01_ngo.id]) - in domain["this_partner_id"] - ) - self.assertTrue( - ("category_id", "in", [self.category_02_volunteer.id]) - in domain["other_partner_id"] - ) - # 4. Test with invalid or impossible combinations - relation_nobody = self._get_empty_relation() - relation_nobody.type_selection_id = self.selection_nobody - warning = relation_nobody.onchange_type_selection_id()["warning"] - self.assertTrue("message" in warning) - self.assertTrue("No this partner available" in warning["message"]) - relation_nobody.this_partner_id = self.partner_02_company - warning = relation_nobody.onchange_type_selection_id()["warning"] - self.assertTrue("message" in warning) - self.assertTrue("incompatible" in warning["message"]) - # Allow left partner and check message for other partner: - self.type_nobody.write({"partner_category_left": False}) - self.type_nobody.flush_recordset() - self.selection_nobody.invalidate_recordset() - warning = relation_nobody.onchange_type_selection_id()["warning"] - self.assertTrue("message" in warning) - self.assertTrue("No other partner available" in warning["message"]) - - def test_on_change_partner_id(self): - """Test on_change_partner_id.""" - # 1. Test call with empty relation - relation_empty = self._get_empty_relation() - result = relation_empty.onchange_partner_id() - self.assertTrue("domain" in result) - self.assertFalse("warning" in result) - self.assertTrue("type_selection_id" in result["domain"]) - self.assertFalse(result["domain"]["type_selection_id"]) - # 2. Test call with company 2 person relation - relation = self._create_company2person_relation() - domain = relation.onchange_partner_id()["domain"] - self.assertTrue(("contact_type_this", "=", "c") in domain["type_selection_id"]) - # 3. Test with invalid or impossible combinations - relation_nobody = self._get_empty_relation() - relation_nobody.this_partner_id = self.partner_02_company - relation_nobody.type_selection_id = self.selection_nobody - warning = relation_nobody.onchange_partner_id()["warning"] - self.assertTrue("message" in warning) - self.assertTrue("incompatible" in warning["message"]) - - def test_write(self): - """Test write. Special attention for changing type.""" - relation_company2person = self._create_company2person_relation() - company_partner = relation_company2person.this_partner_id - # First get another worker: - partner_extra_person = self.partner_model.create( - {"name": "A new worker", "is_company": False, "ref": "NW01"} - ) - relation_company2person.write({"other_partner_id": partner_extra_person.id}) - self.assertEqual( - relation_company2person.other_partner_id.name, partner_extra_person.name - ) - # We will also change to a type going from person to company: - ( - type_worker2company, - selection_worker2company, - selection_company2worker, - ) = self._create_relation_type_selection( - { - "name": "works for", - "name_inverse": "has worker", - "contact_type_left": "p", - "contact_type_right": "c", - } - ) - relation_company2person.write( - { - "this_partner_id": partner_extra_person.id, - "type_selection_id": selection_worker2company.id, - "other_partner_id": company_partner.id, - } - ) - self.assertEqual( - relation_company2person.this_partner_id.id, partner_extra_person.id - ) - self.assertEqual( - relation_company2person.type_selection_id.id, selection_worker2company.id - ) - self.assertEqual( - relation_company2person.other_partner_id.id, company_partner.id - ) diff --git a/partner_multi_relation/tests/test_partner_relation_common.py b/partner_multi_relation/tests/test_partner_relation_common.py index e9cda66af15..c1906b6903b 100644 --- a/partner_multi_relation/tests/test_partner_relation_common.py +++ b/partner_multi_relation/tests/test_partner_relation_common.py @@ -1,107 +1,67 @@ -# Copyright 2016 Therp BV +# Copyright 2016-2025 Therp BV # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). from odoo.tests import common class TestPartnerRelationCommon(common.TransactionCase): - def setUp(self): - super(TestPartnerRelationCommon, self).setUp() - - self.partner_model = self.env["res.partner"] - self.category_model = self.env["res.partner.category"] - self.type_model = self.env["res.partner.relation.type"] - self.selection_model = self.env["res.partner.relation.type.selection"] - self.relation_model = self.env["res.partner.relation"] - self.relation_all_model = self.env["res.partner.relation.all"] - self.partner_01_person = self.partner_model.create( + @classmethod + def setUpClass(cls): + """Main Set Up Class.""" + super().setUpClass() + cls.Partner = cls.env["res.partner"] + cls.PartnerCategory = cls.env["res.partner.category"] + cls.RelationType = cls.env["res.partner.relation.type"] + cls.Relation = cls.env["res.partner.relation"] + cls.partner_01_person = cls.Partner.create( {"name": "Test User 1", "is_company": False, "ref": "PR01"} ) - self.partner_02_company = self.partner_model.create( + cls.partner_02_company = cls.Partner.create( {"name": "Test Company", "is_company": True, "ref": "PR02"} ) # Create partners with specific categories: - self.category_01_ngo = self.category_model.create({"name": "NGO"}) - self.partner_03_ngo = self.partner_model.create( + cls.category_01_ngo = cls.PartnerCategory.create({"name": "NGO"}) + cls.partner_03_ngo = cls.Partner.create( { "name": "Test NGO", "is_company": True, "ref": "PR03", - "category_id": [(4, self.category_01_ngo.id)], + "category_id": [(4, cls.category_01_ngo.id)], } ) - self.category_02_volunteer = self.category_model.create({"name": "Volunteer"}) - self.partner_04_volunteer = self.partner_model.create( + cls.category_02_volunteer = cls.PartnerCategory.create({"name": "Volunteer"}) + cls.partner_04_volunteer = cls.Partner.create( { "name": "Test Volunteer", "is_company": False, "ref": "PR04", - "category_id": [(4, self.category_02_volunteer.id)], + "category_id": [(4, cls.category_02_volunteer.id)], } ) # Create a new relation type withouth categories: - ( - self.type_company2person, - self.selection_company2person, - self.selection_person2company, - ) = self._create_relation_type_selection( + cls.type_company2person = cls.RelationType.create( { - "name": "mixed", - "name_inverse": "mixed_inverse", + "name": "has contact", + "name_inverse": "is contact for", "contact_type_left": "c", "contact_type_right": "p", + "handle_invalid_onchange": "restrict", } ) # Create a new relation type with categories: - ( - self.type_ngo2volunteer, - self.selection_ngo2volunteer, - self.selection_volunteer2ngo, - ) = self._create_relation_type_selection( + cls.type_ngo2volunteer = cls.RelationType.create( { "name": "NGO has volunteer", "name_inverse": "volunteer works for NGO", "contact_type_left": "c", "contact_type_right": "p", - "partner_category_left": self.category_01_ngo.id, - "partner_category_right": self.category_02_volunteer.id, + "partner_category_left": cls.category_01_ngo.id, + "partner_category_right": cls.category_02_volunteer.id, } ) - - def _create_relation_type_selection(self, vals): - """Create relation type and return this with selection types.""" - assert "name" in vals, ( - "Name missing in vals to create relation type. Vals: %s." % vals - ) - assert "name" in vals, ( - "Name_inverse missing in vals to create relation type. Vals: %s." % vals - ) - vals_list = [vals] - new_type = self.type_model.create(vals_list) - self.assertTrue(new_type, msg="No relation type created with vals %s." % vals) - selection_types = self.selection_model.search([("type_id", "=", new_type.id)]) - for st in selection_types: - if st.is_inverse: - inverse_type_selection = st - else: - type_selection = st - self.assertTrue( - inverse_type_selection, - msg="Failed to find inverse type selection based on" - " relation type created with vals %s." % vals, - ) - self.assertTrue( - type_selection, - msg="Failed to find type selection based on" - " relation type created with vals %s." % vals, - ) - return (new_type, type_selection, inverse_type_selection) - - def _create_company2person_relation(self): - """Utility function to get a relation from company 2 partner.""" - return self.relation_all_model.create( + cls.company2person_relation = cls.Relation.create( { - "type_selection_id": self.selection_company2person.id, - "this_partner_id": self.partner_02_company.id, - "other_partner_id": self.partner_01_person.id, + "left_partner_id": cls.partner_02_company.id, + "type_id": cls.type_company2person.id, + "right_partner_id": cls.partner_01_person.id, } ) diff --git a/partner_multi_relation/tests/test_partner_relation_type.py b/partner_multi_relation/tests/test_partner_relation_type.py new file mode 100644 index 00000000000..68405a85812 --- /dev/null +++ b/partner_multi_relation/tests/test_partner_relation_type.py @@ -0,0 +1,145 @@ +# Copyright 2025 Therp BV . +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from psycopg2.errors import ForeignKeyViolation + +from odoo.exceptions import ValidationError +from odoo.tools.misc import mute_logger + +from .test_partner_relation_common import TestPartnerRelationCommon + + +class TestPartnerRelationType(TestPartnerRelationCommon): + @mute_logger("odoo.sql_db") + def test_unlink(self): + """Unlink should fail if existing relations are not unlinked also.""" + relation_type = self.type_company2person + relation = self.company2person_relation + with self.assertRaises(ForeignKeyViolation): + relation_type.unlink() + relation_type.write({"handle_invalid_onchange": "delete"}) + self.assertTrue(relation_type.unlink()) + self.assertFalse(relation.exists()) + + def test_write_contact_type(self): + """Create relation company 2 person, then change left type to person.""" + relation_type = self.type_company2person + relation = self.company2person_relation + self.assertTrue(relation.left_partner_id.is_company) + with self.assertRaises(ValidationError): + relation_type.write({"contact_type_left": "p"}) + # But should be OK if we end the incompatible relations. + relation_type.write( + { + "handle_invalid_onchange": "end", + "contact_type_left": "p", + } + ) + self.assertTrue(relation.date_end) + + def test_write_contact_type_company(self): + """Create relation company 2 person, then change right type to company.""" + relation_type = self.type_company2person + relation = self.company2person_relation + self.assertFalse(relation.right_partner_id.is_company) + with self.assertRaises(ValidationError): + relation_type.write({"contact_type_right": "c"}) + # But should be OK if we delete the incompatible relations. + relation_type.write( + { + "handle_invalid_onchange": "delete", + "contact_type_right": "c", + } + ) + self.assertFalse(relation.exists()) + + def test_write_remove_contact_types(self): + """Create relation company 2 person, then make all kind of changes possible.""" + relation_type = self.type_company2person + relation = self.company2person_relation + relation_type.write( + { + "contact_type_left": False, + "contact_type_right": False, + } + ) + # Now we should be able to add person left and company right. + self.assertTrue( + relation.write( + { + "left_partner_id": self.partner_04_volunteer.id, + "right_partner_id": self.partner_03_ngo.id, + } + ) + ) + + def test_write_category(self): + """Create relation company 2 person, then change left category to ngo.""" + relation_type = self.type_company2person + relation = self.company2person_relation + self.assertFalse(relation.left_partner_id.category_id) + with self.assertRaises(ValidationError): + relation_type.write({"partner_category_left": self.category_01_ngo.id}) + # But should be OK if we ignore the incompatible relations. + relation_type.write( + { + "handle_invalid_onchange": "ignore", + "partner_category_left": self.category_01_ngo.id, + } + ) + self.assertFalse(relation.left_partner_id.category_id) + + def test_write_with_no_incompatible_relations(self): + """Create relation type without conditions, relation, then add conditions.""" + type_party_volunteer = self.RelationType.create( + { + "name": "party has volunteer", + "name_inverse": "volunteer works for party", + "handle_invalid_onchange": "restrict", + } + ) + category_party = self.PartnerCategory.create({"name": "Party"}) + partner_peoples_will = self.Partner.create( + { + "name": "People's Will", + "is_company": True, + "ref": "PPLWIL", + "category_id": [(4, category_party.id)], + } + ) + self.Relation.create( + { + "left_partner_id": partner_peoples_will.id, + "type_id": type_party_volunteer.id, + "right_partner_id": self.partner_04_volunteer.id, + } + ) + self.assertTrue( + type_party_volunteer.write( + { + "contact_type_left": "c", + "contact_type_right": "p", + "partner_category_left": category_party.id, + "partner_category_right": self.category_02_volunteer.id, + } + ) + ) + + def test_symmetric(self): + """When making connection symmetric, set right values to left values.""" + relation_type = self.RelationType.create( + { + "name": "is related to", + "name_inverse": "has a relation to", + "contact_type_left": "p", + "partner_category_left": self.category_01_ngo.id, + } + ) + relation_type.write({"is_symmetric": True}) + self.assertTrue(relation_type.is_symmetric) + self.assertEqual(relation_type.name_inverse, relation_type.name) + self.assertEqual( + relation_type.contact_type_right, relation_type.contact_type_left + ) + self.assertEqual( + relation_type.partner_category_right, relation_type.partner_category_left + ) diff --git a/partner_multi_relation/tests/test_partner_search.py b/partner_multi_relation/tests/test_partner_search.py index c200e045e21..bbbc88527b0 100644 --- a/partner_multi_relation/tests/test_partner_search.py +++ b/partner_multi_relation/tests/test_partner_search.py @@ -1,6 +1,8 @@ -# Copyright 2015 Camptocamp SA -# Copyright 2016 Therp BV +# Copyright 2015 Camptocamp SA. +# Copyright 2016-2025 Therp BV . # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from datetime import timedelta + from odoo import fields from odoo.exceptions import ValidationError @@ -8,68 +10,144 @@ class TestPartnerSearch(TestPartnerRelationCommon): - def test_search_relation_type(self): + def test_search_type(self): """Test searching on relation type.""" - relation = self._create_company2person_relation() - partners = self.partner_model.search( - [("search_relation_type_id", "=", relation.type_selection_id.id)] + relation = self.company2person_relation + partners = self.Partner.search( + [("search_relation_type_id", "=", relation.type_id.id)] ) self.assertTrue(self.partner_02_company in partners) - partners = self.partner_model.search( - [("search_relation_type_id", "!=", relation.type_selection_id.id)] - ) self.assertTrue(self.partner_01_person in partners) - partners = self.partner_model.search( + partners = self.Partner.search( [("search_relation_type_id", "=", self.type_company2person.name)] ) self.assertTrue(self.partner_01_person in partners) self.assertTrue(self.partner_02_company in partners) - partners = self.partner_model.search( + partners = self.Partner.search( + # Like 'has cont'. + [("search_relation_type_id", "ilike", self.type_company2person.name[:8])] + ) + self.assertTrue(self.partner_01_person in partners) + self.assertTrue(self.partner_02_company in partners) + partners = self.Partner.search( [("search_relation_type_id", "=", "unknown relation")] ) self.assertFalse(partners) # Check error with invalid search operator: with self.assertRaises(ValidationError): - partners = self.partner_model.search( + partners = self.Partner.search( [("search_relation_type_id", "child_of", "some parent")] ) + def test_get_domain_relation_search(self): + """Test searching on related partner.""" + domain = [ + ("search_relation_partner_id", "=", self.partner_02_company.id), + ("search_relation_date", "=", fields.Date.today()), + ("name", "ilike", "user"), + ] + relation_search = self.Partner._get_domain_relation_search(domain) + self.assertIn("search_relation_partner_id", relation_search) + self.assertIn("search_relation_date", relation_search) + self.assertNotIn("name", relation_search) + + def test_update_domain_relation_search(self): + """Check injection of date and active, when updating domain.""" + domain = [("search_relation_partner_id", "=", self.partner_02_company.id)] + relation_search = self.Partner._get_domain_relation_search(domain) + self.assertEqual(relation_search, ["search_relation_partner_id"]) + domain = self.Partner._update_domain_relation_search(domain, relation_search) + self.assertIn(("relation_right_ids.active", "=", True), domain) + relation_search = self.Partner._get_domain_relation_search(domain) + self.assertIn("search_relation_date", relation_search) + def test_search_relation_partner(self): """Test searching on related partner.""" - self._create_company2person_relation() - partners = self.partner_model.search( + partners = self.Partner.search( [("search_relation_partner_id", "=", self.partner_02_company.id)] ) self.assertTrue(self.partner_01_person in partners) - def test_search_relation_date(self): + def test_search_relation_date_today(self): """Test searching on relations valid on a certain date.""" - self._create_company2person_relation() - partners = self.partner_model.search( + partners = self.Partner.search( [("search_relation_date", "=", fields.Date.today())] ) self.assertTrue(self.partner_01_person in partners) self.assertTrue(self.partner_02_company in partners) + def test_search_relation_date_future(self): + """Test searching on relations valid on a certain date.""" + today = fields.Date.today() + one_day = timedelta(days=1) + tomorrow = today + one_day + day_after_tomorrow = tomorrow + one_day + great_company = self.Partner.create( + {"name": "Great Company", "is_company": True, "ref": "GRTCOM"} + ) + hard_working_person = self.Partner.create( + {"name": "Hard Working Person", "is_company": False, "ref": "HRDWRK"} + ) + self.Relation.create( + { + "left_partner_id": great_company.id, + "type_id": self.type_company2person.id, + "right_partner_id": hard_working_person.id, + "date_start": tomorrow, + "date_end": False, + } + ) + partners = self.Partner.search([("search_relation_date", "=", today)]) + self.assertTrue(self.partner_01_person in partners) + self.assertTrue(self.partner_02_company in partners) + self.assertFalse(great_company in partners) + self.assertFalse(hard_working_person in partners) + self.company2person_relation.write({"date_end": today}) + partners = self.Partner.search( + [("search_relation_date", "=", day_after_tomorrow)] + ) + self.assertFalse(self.partner_01_person in partners) + self.assertFalse(self.partner_02_company in partners) + self.assertTrue(great_company in partners) + self.assertTrue(hard_working_person in partners) + + def test_search_active_inactive(self): + """Test searching on partners with active and inactive relations.""" + domain = [("search_relation_type_id", "=", self.type_company2person.name)] + partners = self.Partner.search(domain) + self.assertTrue(self.partner_01_person in partners) + self.assertTrue(self.partner_02_company in partners) + relation = self.company2person_relation + relation.write({"active": False}) + partners = self.Partner.search(domain) + self.assertFalse(self.partner_01_person in partners) + self.assertFalse(self.partner_02_company in partners) + # Same if we explicitly pass active_test=True. + partners = self.Partner.with_context(active_test=True).search(domain) + self.assertFalse(self.partner_01_person in partners) + self.assertFalse(self.partner_02_company in partners) + partners = self.Partner.with_context(active_test=False).search(domain) + self.assertTrue(self.partner_01_person in partners) + self.assertTrue(self.partner_02_company in partners) + def test_search_any_partner(self): - """Test searching for partner left or right.""" - self._create_company2person_relation() - both_relations = self.relation_all_model.search( - [("any_partner_id", "=", self.partner_02_company.id)] + """Test searching for partner that has a relation with searched partner.""" + partners = self.Partner.search( + [("search_relation_partner_id", "ilike", "user")] ) - self.assertEqual(len(both_relations), 2) + self.assertIn(self.partner_02_company, partners) def test_search_partner_category(self): """Test searching for partners related to partners having category.""" - relation_ngo_volunteer = self.relation_all_model.create( + relation_ngo_volunteer = self.Relation.create( { - "this_partner_id": self.partner_03_ngo.id, - "type_selection_id": self.selection_ngo2volunteer.id, - "other_partner_id": self.partner_04_volunteer.id, + "left_partner_id": self.partner_03_ngo.id, + "type_id": self.type_ngo2volunteer.id, + "right_partner_id": self.partner_04_volunteer.id, } ) self.assertTrue(relation_ngo_volunteer) - partners = self.partner_model.search( + partners = self.Partner.search( [ ( "search_relation_partner_category_id", diff --git a/partner_multi_relation/views/ir_actions_act_window.xml b/partner_multi_relation/views/ir_actions_act_window.xml index ad77356b56a..8eb795428f8 100644 --- a/partner_multi_relation/views/ir_actions_act_window.xml +++ b/partner_multi_relation/views/ir_actions_act_window.xml @@ -5,20 +5,12 @@ res.partner.relation.type tree,form - - Show partner's relations - - res.partner.relation.all - tree,form - [('this_partner_id', 'in', active_ids)] - - + Relations - res.partner.relation.all - tree - - - {'active_test': 1} + res.partner.relation + tree,form + +

Record and track your partners' relations. Relations may diff --git a/partner_multi_relation/views/ir_ui_menu.xml b/partner_multi_relation/views/ir_ui_menu.xml index 9e85ae073e4..6cca7f0821e 100644 --- a/partner_multi_relation/views/ir_ui_menu.xml +++ b/partner_multi_relation/views/ir_ui_menu.xml @@ -7,10 +7,10 @@ parent="contacts.menu_contacts" /> + + + + + diff --git a/partner_multi_relation/views/res_partner_relation.xml b/partner_multi_relation/views/res_partner_relation.xml new file mode 100644 index 00000000000..a2a8de6776d --- /dev/null +++ b/partner_multi_relation/views/res_partner_relation.xml @@ -0,0 +1,78 @@ + + + + res.partner.relation + + + + + + + + + + + + + + + + res.partner.relation + + + + + + + + + + + + + res.partner.relation + +

+ + + + + + + + + + + + +
+ diff --git a/partner_multi_relation/views/res_partner_relation_all.xml b/partner_multi_relation/views/res_partner_relation_all.xml deleted file mode 100644 index 032e3dfd750..00000000000 --- a/partner_multi_relation/views/res_partner_relation_all.xml +++ /dev/null @@ -1,76 +0,0 @@ - - - - res.partner.relation.all - - - - - - - - - - - - - res.partner.relation.all - - - - - - - - - - - - - - - - - - diff --git a/partner_multi_relation_contact/README.rst b/partner_multi_relation_contact/README.rst new file mode 100644 index 00000000000..1f48a4541e4 --- /dev/null +++ b/partner_multi_relation_contact/README.rst @@ -0,0 +1,116 @@ +======================== +Partner Relation Contact +======================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:f5bedfce28db3d0081c34e3c73d93c3be293d8248a5143c99f135b8537f54ad1 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fpartner--contact-lightgray.png?logo=github + :target: https://github.com/OCA/partner-contact/tree/16.0/partner_multi_relation_contact + :alt: OCA/partner-contact +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/partner-contact-16-0/partner-contact-16-0-partner_multi_relation_contact + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/partner-contact&target_branch=16.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module gives the posibility to set contact information on a relation. + +Sometimes the email address, the phone number or the physical address to +use with a contact can depend on the role, or relation, for which we want +to approach a contact, or function. + +For instance a company can have a company email info@example.company.com. A +lawyer with the email address somelawyer@legalcompany.com is working for that +company. So we have a relation 'Example company is represented by lawyer some lawyer'. +But for this relation an email has been created 'legaldepartment@example.company.com'. + +Similar examples can be given for phone or physical address. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +Relation Type +~~~~~~~~~~~~~ + +You can specify that a relation type can have a separate contact attached to it. You can +specify that the separate address can have an email, a phone and/or a physical address. + +You can also specify a priority to use for which contact, left or right, to use for email, +phone or physical address, if not specified on the attached contact (or if there is no +attached contact). + +Relation +~~~~~~~~ + +You can specify a special contact for relation types that allow this. Depending +on the type you can enter email, phone and or physical address. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Therp BV + +Contributors +~~~~~~~~~~~~ + +* `Therp BV `_: + + * Ronald Portier + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-NL66278| image:: https://github.com/NL66278.png?size=40px + :target: https://github.com/NL66278 + :alt: NL66278 + +Current `maintainer `__: + +|maintainer-NL66278| + +This module is part of the `OCA/partner-contact `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/partner_multi_relation_contact/__init__.py b/partner_multi_relation_contact/__init__.py new file mode 100644 index 00000000000..c32fd62b78d --- /dev/null +++ b/partner_multi_relation_contact/__init__.py @@ -0,0 +1,2 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from . import models diff --git a/partner_multi_relation_contact/__manifest__.py b/partner_multi_relation_contact/__manifest__.py new file mode 100644 index 00000000000..a16f66437ef --- /dev/null +++ b/partner_multi_relation_contact/__manifest__.py @@ -0,0 +1,26 @@ +# Copyright 2026 Therp BV . +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +{ + "name": "Partner Relation Contact", + "version": "16.0.1.0.0", + "author": "Therp BV,Odoo Community Association (OCA)", + "website": "https://github.com/OCA/partner-contact", + "maintainers": ["NL66278"], + "complexity": "normal", + "category": "Customer Relationship Management", + "license": "AGPL-3", + "depends": ["partner_multi_relation"], + "demo": [ + "demo/res_partner_relation_type_demo.xml", + "demo/res_partner_demo.xml", + "demo/res_partner_demo.xml", # Must be after type and partner + "demo/res_partner_02_demo.xml", # Create contact address for relation + ], + "data": [ + "views/res_partner_views.xml", + "views/res_partner_relation_views.xml", + "views/res_partner_relation_type_views.xml", + ], + "auto_install": False, + "installable": True, +} diff --git a/partner_multi_relation_contact/demo/res_partner_02_demo.xml b/partner_multi_relation_contact/demo/res_partner_02_demo.xml new file mode 100644 index 00000000000..78de88009ce --- /dev/null +++ b/partner_multi_relation_contact/demo/res_partner_02_demo.xml @@ -0,0 +1,14 @@ + + + + + + other + + Joe Clever is head lawyer for Big Company + 0 + joeclever@legalcompany.example.com + +31 06 6461 7838 + + + diff --git a/partner_multi_relation_contact/demo/res_partner_demo.xml b/partner_multi_relation_contact/demo/res_partner_demo.xml new file mode 100644 index 00000000000..67e051e0a18 --- /dev/null +++ b/partner_multi_relation_contact/demo/res_partner_demo.xml @@ -0,0 +1,15 @@ + + + + + Joe Clever + 0 + Hilversum + 1222 EE + + joeclever@legalcompany.example.com + +31 06 6461 0401 + Buisweg + + + diff --git a/partner_multi_relation_contact/demo/res_partner_relation_demo.xml b/partner_multi_relation_contact/demo/res_partner_relation_demo.xml new file mode 100644 index 00000000000..ada7cce6cd0 --- /dev/null +++ b/partner_multi_relation_contact/demo/res_partner_relation_demo.xml @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/partner_multi_relation_contact/demo/res_partner_relation_type_demo.xml b/partner_multi_relation_contact/demo/res_partner_relation_type_demo.xml new file mode 100644 index 00000000000..c493347b949 --- /dev/null +++ b/partner_multi_relation_contact/demo/res_partner_relation_type_demo.xml @@ -0,0 +1,12 @@ + + + + Has lawyer + Is lawyer for + c + p + True + True + True + + diff --git a/partner_multi_relation_contact/models/__init__.py b/partner_multi_relation_contact/models/__init__.py new file mode 100644 index 00000000000..c09502ef0c4 --- /dev/null +++ b/partner_multi_relation_contact/models/__init__.py @@ -0,0 +1,4 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from . import res_partner +from . import res_partner_relation_type +from . import res_partner_relation diff --git a/partner_multi_relation_contact/models/res_partner.py b/partner_multi_relation_contact/models/res_partner.py new file mode 100644 index 00000000000..cedcba4307a --- /dev/null +++ b/partner_multi_relation_contact/models/res_partner.py @@ -0,0 +1,68 @@ +# Copyright 2026 Therp BV . +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import api, fields, models + + +class ResPartner(models.Model): + """Enable searching partner via email""" + + _inherit = "res.partner" + + relation_id = fields.Many2one( + comodel_name="res.partner.relation", + readonly=True, + help="Relation for which this contact address has been created", + ) + allow_contact_partner = fields.Boolean( + related="relation_id.type_id.allow_contact_partner", + ) + allow_address = fields.Boolean( + related="relation_id.type_id.allow_address", + ) + allow_email = fields.Boolean( + related="relation_id.type_id.allow_email", + ) + allow_phone = fields.Boolean( + related="relation_id.type_id.allow_phone", + ) + search_relation_email = fields.Many2one( + comodel_name="res.partner.relation", + compute=lambda self: self.update({"search_relation_email": None}), + search="_search_relation_email", + string="Has relation email", + ) + search_relation_phone = fields.Many2one( + comodel_name="res.partner.relation", + compute=lambda self: self.update({"search_relation_phone": None}), + search="_search_relation_phone", + string="Has relation phone", + ) + + @api.model_create_multi + def create(self, vals_list): + result = super().create(vals_list) + for record in result: + if record.relation_id: + record.relation_id.contact_partner_id = record + return result + + @api.model + def _search_relation_email(self, operator, value): + """Search partners based on their relation email.""" + self._check_supported_operator(operator) + return [ + "|", + ("relation_left_ids.contact_partner_id.email", operator, value), + ("relation_right_ids.contact_partner_id.email", operator, value), + ] + + @api.model + def _search_relation_phone(self, operator, value): + """Search partners based on their relation phone.""" + self._check_supported_operator(operator) + return [ + "|", + ("relation_left_ids.contact_partner_id.phone", operator, value), + ("relation_right_ids.contact_partner_id.phone", operator, value), + ] diff --git a/partner_multi_relation_contact/models/res_partner_relation.py b/partner_multi_relation_contact/models/res_partner_relation.py new file mode 100644 index 00000000000..5665f4cb7a9 --- /dev/null +++ b/partner_multi_relation_contact/models/res_partner_relation.py @@ -0,0 +1,114 @@ +# Copyright 2026 Therp BV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + + +class ResPartnerRelation(models.Model): + + _inherit = "res.partner.relation" + + contact_partner_id = fields.Many2one( + comodel_name="res.partner", + domain=[("type", "=", "other")], + ) + email_partner_id = fields.Many2one( + # This field can be used to set the partner to email to, when + # using the message compose wizard. + comodel_name="res.partner", + compute="_compute_email_partner_id", + ) + allow_contact_partner = fields.Boolean( + related="type_id.allow_contact_partner", + ) + email = fields.Char(compute="_compute_email") + phone = fields.Char(compute="_compute_phone") + + @api.constrains("contact_partner_id") + def _check_contact_address(self): + """Address should only be filled when allowed on type.""" + for this in self: + if this.contact_partner_id and not this.type_id.allow_contact_partner: + raise ValidationError( + _( + "You can not have a contact partner on relations" + " of type %(type)s.", + type=this.type_id.display_name, + ) + ) + + @api.depends( + "left_partner_id", + "left_partner_id.email", + "right_partner_id", + "right_partner_id.email", + "contact_partner_id", + "contact_partner_id.email", + "type_id", + "type_id.preferred_contact", + ) + def _compute_email_partner_id(self): + for this in self: + preferred_contact, fallback_contact = this._get_contact_preference() + this.email_partner_id = ( + this.contact_partner_id.email + and this.contact_partner_id + or preferred_contact.email + and preferred_contact + or fallback_contact.email + and fallback_contact + or False + ) + + @api.depends("email_partner_id") + def _compute_email(self): + for this in self: + this.email = this.email_partner_id.email # False if no email partner. + + def _compute_phone(self): + for this in self: + preferred_contact, fallback_contact = this._get_contact_preference() + this.phone = ( + this.contact_partner_id.phone + or preferred_contact.phone + or fallback_contact.phone + or False + ) + + def _get_contact_preference(self): + self.ensure_one() + if self.type_id.preferred_contact == "left_partner": + return self.left_partner_id, self.right_partner_id + return self.right_partner_id, self.left_partner_id + + def unlink(self): + contacts = self.mapped("contact_partner_id") + contacts.unlink() + return super().unlink() + + def action_contact_address(self): + self.ensure_one() + preferred_contact, fallback_contact = self._get_contact_preference() + form_view = self.env.ref( + "partner_multi_relation_contact.form_res_partner_contact_address" + ) + context = { + "default_relation_id": self.id, + "default_name": self.with_context( + current_contact_id=preferred_contact.id + ).display_name, + "default_type": "other", + "default_is_company": False, + } + context["default_parent_id"] = ( + preferred_contact.id if self.type_id.set_contact_parent else False + ) + return { + "type": "ir.actions.act_window", + "res_model": "res.partner", + "name": _("Contact address for %(relation)s", relation=self.display_name), + "view_mode": "form", + "views": [(form_view.id, "form")], + "context": context, + "target": "top", + } diff --git a/partner_multi_relation_contact/models/res_partner_relation_type.py b/partner_multi_relation_contact/models/res_partner_relation_type.py new file mode 100644 index 00000000000..5d6107e9219 --- /dev/null +++ b/partner_multi_relation_contact/models/res_partner_relation_type.py @@ -0,0 +1,48 @@ +# Copyright 2026 Therp BV . +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import api, fields, models + + +class ResPartnerRelationType(models.Model): + + _inherit = "res.partner.relation.type" + + allow_contact_partner = fields.Boolean( + help="If set, allow to link connection to an address (partner) record" + " to specify email, phone or physical address specific for the relation", + compute="_compute_allow_contact_partner", + store=True, + ) + allow_email = fields.Boolean( + help="If set, allows to specify a specific email for this relation", + ) + allow_phone = fields.Boolean( + help="If set, allows to specify a specific phone for this relation", + ) + allow_address = fields.Boolean( + help="If set, allows to specify a specific address for this relation", + ) + preferred_contact = fields.Selection( + [ + ("left_partner", "Left Partner"), + ("right_partner", "Right Partner"), + ], + default="right_partner", + help="Partner to use for email, phone or address, if no contact address" + " partner, or not set on contact address partner.", + ) + set_contact_parent = fields.Boolean( + default=True, + help="Set parent on address contact to preferred partner", + ) + + @api.depends( + "allow_email", + "allow_phone", + "allow_address", + ) + def _compute_allow_contact_partner(self): + for this in self: + this.allow_contact_partner = ( + this.allow_email or this.allow_phone or this.allow_address + ) diff --git a/partner_multi_relation_contact/readme/CONTRIBUTORS.rst b/partner_multi_relation_contact/readme/CONTRIBUTORS.rst new file mode 100644 index 00000000000..f9370b5d9b1 --- /dev/null +++ b/partner_multi_relation_contact/readme/CONTRIBUTORS.rst @@ -0,0 +1,3 @@ +* `Therp BV `_: + + * Ronald Portier diff --git a/partner_multi_relation_contact/readme/DESCRIPTION.rst b/partner_multi_relation_contact/readme/DESCRIPTION.rst new file mode 100644 index 00000000000..d46c477945a --- /dev/null +++ b/partner_multi_relation_contact/readme/DESCRIPTION.rst @@ -0,0 +1,12 @@ +This module gives the posibility to set contact information on a relation. + +Sometimes the email address, the phone number or the physical address to +use with a contact can depend on the role, or relation, for which we want +to approach a contact, or function. + +For instance a company can have a company email info@example.company.com. A +lawyer with the email address somelawyer@legalcompany.com is working for that +company. So we have a relation 'Example company is represented by lawyer some lawyer'. +But for this relation an email has been created 'legaldepartment@example.company.com'. + +Similar examples can be given for phone or physical address. diff --git a/partner_multi_relation_contact/readme/USAGE.rst b/partner_multi_relation_contact/readme/USAGE.rst new file mode 100644 index 00000000000..bb355f75ca8 --- /dev/null +++ b/partner_multi_relation_contact/readme/USAGE.rst @@ -0,0 +1,15 @@ +Relation Type +~~~~~~~~~~~~~ + +You can specify that a relation type can have a separate contact attached to it. You can +specify that the separate address can have an email, a phone and/or a physical address. + +You can also specify a priority to use for which contact, left or right, to use for email, +phone or physical address, if not specified on the attached contact (or if there is no +attached contact). + +Relation +~~~~~~~~ + +You can specify a special contact for relation types that allow this. Depending +on the type you can enter email, phone and or physical address. diff --git a/partner_multi_relation_contact/static/description/icon.png b/partner_multi_relation_contact/static/description/icon.png new file mode 100644 index 00000000000..1dcc49c24f3 Binary files /dev/null and b/partner_multi_relation_contact/static/description/icon.png differ diff --git a/partner_multi_relation_contact/static/description/index.html b/partner_multi_relation_contact/static/description/index.html new file mode 100644 index 00000000000..346f61fdf6f --- /dev/null +++ b/partner_multi_relation_contact/static/description/index.html @@ -0,0 +1,454 @@ + + + + + +Partner Relation Contact + + + +
+

Partner Relation Contact

+ + +

Beta License: AGPL-3 OCA/partner-contact Translate me on Weblate Try me on Runboat

+

This module gives the posibility to set contact information on a relation.

+

Sometimes the email address, the phone number or the physical address to +use with a contact can depend on the role, or relation, for which we want +to approach a contact, or function.

+

For instance a company can have a company email info@example.company.com. A +lawyer with the email address somelawyer@legalcompany.com is working for that +company. So we have a relation ‘Example company is represented by lawyer some lawyer’. +But for this relation an email has been created ‘legaldepartment@example.company.com’.

+

Similar examples can be given for phone or physical address.

+

Table of contents

+ +
+

Usage

+
+

Relation Type

+

You can specify that a relation type can have a separate contact attached to it. You can +specify that the separate address can have an email, a phone and/or a physical address.

+

You can also specify a priority to use for which contact, left or right, to use for email, +phone or physical address, if not specified on the attached contact (or if there is no +attached contact).

+
+
+

Relation

+

You can specify a special contact for relation types that allow this. Depending +on the type you can enter email, phone and or physical address.

+
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Therp BV
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+Odoo Community Association +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

Current maintainer:

+

NL66278

+

This module is part of the OCA/partner-contact project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/partner_multi_relation_contact/tests/__init__.py b/partner_multi_relation_contact/tests/__init__.py new file mode 100644 index 00000000000..17a5582a680 --- /dev/null +++ b/partner_multi_relation_contact/tests/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from . import test_partner_relation +from . import test_partner_search diff --git a/partner_multi_relation_contact/tests/common.py b/partner_multi_relation_contact/tests/common.py new file mode 100644 index 00000000000..8fd346bd209 --- /dev/null +++ b/partner_multi_relation_contact/tests/common.py @@ -0,0 +1,48 @@ +# Copyright 2026 Therp BV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +import uuid + +from odoo.tests import common + + +class TestCommonCase(common.TransactionCase): + @classmethod + def setUpClass(cls): + """Main Set Up Class.""" + super().setUpClass() + cls.Partner = cls.env["res.partner"] + cls.RelationType = cls.env["res.partner.relation.type"] + cls.Relation = cls.env["res.partner.relation"] + cls.partner_01_person = cls.Partner.create( + {"name": "Test User 1", "is_company": False, "ref": "PR01"} + ) + cls.partner_02_company = cls.Partner.create( + {"name": "Test Company", "is_company": True, "ref": "PR02"} + ) + cls.type_company2person = cls.RelationType.create( + { + "name": "has lawyer", + "name_inverse": "is lawyer for", + "contact_type_left": "c", + "contact_type_right": "p", + "handle_invalid_onchange": "restrict", + } + ) + cls.company2person_relation = cls.Relation.create( + { + "left_partner_id": cls.partner_02_company.id, + "type_id": cls.type_company2person.id, + "right_partner_id": cls.partner_01_person.id, + } + ) + + def _action_contact_address(self, relation): + """Will raise an exception if relation.type_id doesn't allow contact address.""" + action = relation.action_contact_address() + context = action["context"] + vals = self.Partner.with_context(**context).default_get( + fields_list=self.Partner._fields.keys() # Default for all fields + ) + self.assertIn("name", vals) + vals["ref"] = uuid.uuid1() # We need a unique reference in these tests. + return self.Partner.create(vals) diff --git a/partner_multi_relation_contact/tests/test_partner_relation.py b/partner_multi_relation_contact/tests/test_partner_relation.py new file mode 100644 index 00000000000..04a25276fd1 --- /dev/null +++ b/partner_multi_relation_contact/tests/test_partner_relation.py @@ -0,0 +1,27 @@ +# Copyright 2026 Therp BV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from odoo.exceptions import ValidationError + +from .common import TestCommonCase + + +class TestPartnerRelation(TestCommonCase): + def test_allow_contact(self): + relation = self.company2person_relation + relation_type = relation.type_id + self.assertFalse(relation.allow_contact_partner) + with self.assertRaises(ValidationError): + self._action_contact_address(relation) + # Now do allow email and phone, this should allow contact address. + relation_type.write( + { + "allow_email": True, + "allow_phone": True, + } + ) + self.assertTrue(relation_type.allow_contact_partner) + self.assertTrue(relation.allow_contact_partner) + # Should be possible to create contact address now. + contact = self._action_contact_address(relation) + self.assertEqual(contact.relation_id, relation) + self.assertEqual(relation.contact_partner_id, contact) diff --git a/partner_multi_relation_contact/tests/test_partner_search.py b/partner_multi_relation_contact/tests/test_partner_search.py new file mode 100644 index 00000000000..53069b6316f --- /dev/null +++ b/partner_multi_relation_contact/tests/test_partner_search.py @@ -0,0 +1,62 @@ +# Copyright 2026 Therp BV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from odoo.exceptions import ValidationError + +from .common import TestCommonCase + + +class TestPartnerSearch(TestCommonCase): + @classmethod + def setUpClass(cls): + """Main Set Up Class.""" + super().setUpClass() + cls.type_company2person.write( + { + "allow_email": True, + "allow_phone": True, + } + ) + + def test_search_relation_phone(self): + """Test searching for partners having a relation with a specific phone.""" + PHONE = "+31687654321" + relation = self.company2person_relation + self.assertTrue(relation.allow_contact_partner) + contact = self._action_contact_address(relation) + self.assertEqual(relation.contact_partner_id, contact) + contact.write({"phone": PHONE}) + domain = [("search_relation_phone", "=", PHONE), ("type", "=", "contact")] + partners = self.Partner.search(domain) + self.assertEqual(len(partners), 2) + self.assertTrue(self.partner_02_company in partners) + self.assertTrue(self.partner_01_person in partners) + # Try search with invalid operator + domain = [("search_relation_phone", "child_of", PHONE)] + with self.assertRaises(ValidationError): + self.Partner.search(domain) + # Search for non existing phone. + domain = [("search_relation_phone", "=", "not an existing phonenumber")] + partners = self.Partner.search(domain) + self.assertEqual(len(partners), 0) + + def test_search_relation_email(self): + """Test searching for partners having a relation with a specific email.""" + EMAIL = "head_of_legal@bigcompany.example.com" + relation = self.company2person_relation + self.assertTrue(relation.allow_contact_partner) + contact = self._action_contact_address(relation) + self.assertEqual(relation.contact_partner_id, contact) + contact.write({"email": EMAIL}) + domain = [("search_relation_email", "=", EMAIL), ("type", "=", "contact")] + partners = self.Partner.search(domain) + self.assertEqual(len(partners), 2) + self.assertTrue(self.partner_02_company in partners) + self.assertTrue(self.partner_01_person in partners) + # Try search with invalid operator + domain = [("search_relation_email", "child_of", EMAIL)] + with self.assertRaises(ValidationError): + self.Partner.search(domain) + # Search for non existing email. + domain = [("search_relation_email", "=", "notexisting@bigcompany.example.com")] + partners = self.Partner.search(domain) + self.assertEqual(len(partners), 0) diff --git a/partner_multi_relation_contact/views/res_partner_relation_type_views.xml b/partner_multi_relation_contact/views/res_partner_relation_type_views.xml new file mode 100644 index 00000000000..f0ff3901006 --- /dev/null +++ b/partner_multi_relation_contact/views/res_partner_relation_type_views.xml @@ -0,0 +1,32 @@ + + + + + res.partner.relation.type + + + + + + + + + + + + + + + + diff --git a/partner_multi_relation_contact/views/res_partner_relation_views.xml b/partner_multi_relation_contact/views/res_partner_relation_views.xml new file mode 100644 index 00000000000..d4ce5617ffd --- /dev/null +++ b/partner_multi_relation_contact/views/res_partner_relation_views.xml @@ -0,0 +1,74 @@ + + + + + res.partner.relation + + + + + + + + + + + res.partner.relation + + + + + + + + + + + res.partner.relation + + + +
+
+
+ + + + + + + +
+ +
diff --git a/partner_multi_relation_contact/views/res_partner_views.xml b/partner_multi_relation_contact/views/res_partner_views.xml new file mode 100644 index 00000000000..84765734c1f --- /dev/null +++ b/partner_multi_relation_contact/views/res_partner_views.xml @@ -0,0 +1,52 @@ + + + + partner_multi_relation_email.view_partner_filter + + res.partner + + + + + + + + + form.res.partner.contact.address + res.partner + 999 + +
+ + + + + + + + + + + + + + + + + + + + +
+
diff --git a/partner_multi_relation_function/README.rst b/partner_multi_relation_function/README.rst index ccc7f0789ff..d1da1ec6d99 100644 --- a/partner_multi_relation_function/README.rst +++ b/partner_multi_relation_function/README.rst @@ -1,7 +1,3 @@ -.. image:: https://odoo-community.org/readme-banner-image - :target: https://odoo-community.org/get-involved?utm_source=readme - :alt: Odoo Community Association - ========================== Partner Relation Functions ========================== @@ -17,7 +13,7 @@ Partner Relation Functions .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png :target: https://odoo-community.org/page/development-status :alt: Beta -.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html :alt: License: AGPL-3 .. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fpartner--contact-lightgray.png?logo=github diff --git a/partner_multi_relation_function/__manifest__.py b/partner_multi_relation_function/__manifest__.py index 8ca4935dc17..fc96224f0d6 100644 --- a/partner_multi_relation_function/__manifest__.py +++ b/partner_multi_relation_function/__manifest__.py @@ -1,8 +1,8 @@ -# Copyright 2024 Therp BV . +# Copyright 2024-2025 Therp BV . # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). { "name": "Partner Relation Functions", - "version": "16.0.1.1.0", + "version": "16.0.2.0.0", "author": "Therp BV,Odoo Community Association (OCA)", "website": "https://github.com/OCA/partner-contact", "maintainers": ["NL66278"], @@ -17,7 +17,7 @@ ], "data": [ "views/res_partner_views.xml", - "views/res_partner_relation_all_views.xml", + "views/res_partner_relation_views.xml", "views/res_partner_relation_type_views.xml", ], "auto_install": False, diff --git a/partner_multi_relation_function/models/__init__.py b/partner_multi_relation_function/models/__init__.py index 46e589eaf34..c09502ef0c4 100644 --- a/partner_multi_relation_function/models/__init__.py +++ b/partner_multi_relation_function/models/__init__.py @@ -1,6 +1,4 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from . import res_partner from . import res_partner_relation_type -from . import res_partner_relation_type_selection from . import res_partner_relation -from . import res_partner_relation_all diff --git a/partner_multi_relation_function/models/res_partner.py b/partner_multi_relation_function/models/res_partner.py index 4d21be9c36b..ee43d2f7651 100644 --- a/partner_multi_relation_function/models/res_partner.py +++ b/partner_multi_relation_function/models/res_partner.py @@ -11,7 +11,7 @@ class ResPartner(models.Model): _inherit = "res.partner" search_relation_function = fields.Many2one( - comodel_name="res.partner.relation.all", + comodel_name="res.partner.relation", compute=lambda self: self.update({"search_relation_function": None}), search="_search_relation_function", string="Has relation function", @@ -34,15 +34,12 @@ def _search_relation_function(self, operator, value): raise exceptions.ValidationError( _('Unsupported search operator "%s"') % operator ) - relation_model = self.env["res.partner.relation.all"] - relation_function_selection = relation_model.search( - [ - ("function", operator, value), - ] - ) - if not relation_function_selection: + Relation = self.env["res.partner.relation"] + relations_with_function = Relation.search([("function", operator, value)]) + if not relations_with_function: return [FALSE_LEAF] - # Collect both partners, user can apply - # additional type filter for separating contacts - # and companies - return [("relation_all_ids", "in", relation_function_selection.ids)] + return [ + "|", + ("relation_left_ids", "in", relations_with_function.ids), + ("relation_right_ids", "in", relations_with_function.ids), + ] diff --git a/partner_multi_relation_function/models/res_partner_relation.py b/partner_multi_relation_function/models/res_partner_relation.py index 3846204d4ba..059c5d286ea 100644 --- a/partner_multi_relation_function/models/res_partner_relation.py +++ b/partner_multi_relation_function/models/res_partner_relation.py @@ -1,4 +1,4 @@ -# Copyright 2024 Therp BV +# Copyright 2024-2026 Therp BV # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from odoo import _, api, fields, models from odoo.exceptions import ValidationError @@ -9,13 +9,31 @@ class ResPartnerRelation(models.Model): _inherit = "res.partner.relation" function = fields.Char() + allow_function = fields.Boolean( + readonly=True, + related="type_id.allow_function", + ) @api.constrains("function") def _check_function(self): """Function should only be filled when allowed on type.""" - for record in self: - if record.function and not record.type_id.allow_function: + for this in self: + if this.function and not this.type_id.allow_function: raise ValidationError( - _("You can not have a function on relations of type %(type)s."), - {"type": record.type_id.display_name}, + _( + "You can not have a function on relations of type %(type)s.", + type=this.type_id.display_name, + ) ) + + def name_get(self): + """Add function to name if present.""" + wf = _(" with function ") # Prevent repeated translation. + return [ + ( + this.id, + super(ResPartnerRelation, this).name_get()[0][1] + + (this.function and wf + this.function or ""), + ) + for this in self + ] diff --git a/partner_multi_relation_function/models/res_partner_relation_all.py b/partner_multi_relation_function/models/res_partner_relation_all.py deleted file mode 100644 index 1142032dace..00000000000 --- a/partner_multi_relation_function/models/res_partner_relation_all.py +++ /dev/null @@ -1,43 +0,0 @@ -# Copyright 2024 Therp BV -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). - -from odoo import _, fields, models - - -class ResPartnerRelationAll(models.Model): - """Model to show each relation from two sides.""" - - _inherit = "res.partner.relation.all" - - # Override fully _rec_names_search. Not really nice, but for the moment - # the only option. Field should be turned to a property set by a method - # in partner_multi_relation, so would be easily extendable. - _rec_names_search = [ - "this_partner_id.name", - "type_selection_id.name", - "other_partner_id.name", - "function", - ] - - function = fields.Char() - allow_function = fields.Boolean(readonly=True) - - def _get_additional_relation_columns(self): - """Get additionnal columns from res_partner_relation.""" - return super()._get_additional_relation_columns() + ", rel.function" - - def _get_additional_view_fields(self): - """Allow inherit models to add fields to view.""" - return super()._get_additional_view_fields() + ", typ.allow_function" - - def name_get(self): - """Add function to name if present.""" - wf = _(" with function ") # Prevent repeated translation. - return [ - ( - this.id, - super(ResPartnerRelationAll, this).name_get()[0][1] - + (this.function and wf + this.function or ""), - ) - for this in self - ] diff --git a/partner_multi_relation_function/models/res_partner_relation_type_selection.py b/partner_multi_relation_function/models/res_partner_relation_type_selection.py deleted file mode 100644 index 974eed75ff2..00000000000 --- a/partner_multi_relation_function/models/res_partner_relation_type_selection.py +++ /dev/null @@ -1,15 +0,0 @@ -# Copyright 2024 Therp BV -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). - -from odoo import fields, models - - -class ResPartnerRelationTypeSelection(models.Model): - - _inherit = "res.partner.relation.type.selection" - - allow_function = fields.Boolean() - - def _get_additional_view_fields(self): - """Add allow_function to fields.""" - return super()._get_additional_view_fields() + ", allow_function" diff --git a/partner_multi_relation_function/static/description/index.html b/partner_multi_relation_function/static/description/index.html index 758deecd967..29940ff6912 100644 --- a/partner_multi_relation_function/static/description/index.html +++ b/partner_multi_relation_function/static/description/index.html @@ -3,16 +3,15 @@ -README.rst +Partner Relation Functions -
+
+

Partner Relation Functions

- - -Odoo Community Association - -
-

Partner Relation Functions

-

Beta License: AGPL-3 OCA/partner-contact Translate me on Weblate Try me on Runboat

+

Beta License: AGPL-3 OCA/partner-contact Translate me on Weblate Try me on Runboat

This module gives the posibility to have a relation between partners have a function.

Of course there is a function field on partner, but this ignores the fact that persons can have multiple functions depending on the relations they are in. For @@ -397,19 +391,19 @@

Partner Relation Functions

-

Usage

+

Usage

-

Relation Type

+

Relation Type

You can specify that a relation type can have a function attached to it.

-

Relation

+

Relation

You can enter a function for relation types that allow this. The display name for the relation will reflect the function entered, if any.

-

Bug Tracker

+

Bug Tracker

Bugs are tracked on GitHub Issues. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed @@ -417,15 +411,15 @@

Bug Tracker

Do not contact contributors directly about support or help with technical issues.

-

Credits

+

Credits

-

Authors

+

Authors

  • Therp BV
-

Contributors

+

Contributors

-

Maintainers

+

Maintainers

This module is maintained by the OCA.

- -Odoo Community Association - +Odoo Community Association

OCA, or the Odoo Community Association, is a nonprofit organization whose mission is to support the collaborative development of Odoo features and promote its widespread use.

@@ -449,6 +441,5 @@

Maintainers

-
diff --git a/partner_multi_relation_function/tests/__init__.py b/partner_multi_relation_function/tests/__init__.py index 7a7e01ecc78..17a5582a680 100644 --- a/partner_multi_relation_function/tests/__init__.py +++ b/partner_multi_relation_function/tests/__init__.py @@ -1,2 +1,3 @@ # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from . import test_partner_relation from . import test_partner_search diff --git a/partner_multi_relation_function/tests/test_partner_relation.py b/partner_multi_relation_function/tests/test_partner_relation.py new file mode 100644 index 00000000000..df367152c55 --- /dev/null +++ b/partner_multi_relation_function/tests/test_partner_relation.py @@ -0,0 +1,39 @@ +# Copyright 2026 Therp BV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from odoo.exceptions import ValidationError +from odoo.tests import common + + +class TestPartnerRelation(common.TransactionCase): + def test_allow_function(self): + Partner = self.env["res.partner"] + RelationType = self.env["res.partner.relation.type"] + Relation = self.env["res.partner.relation"] + partner_person = Partner.create( + {"name": "Test Participant", "is_company": False, "ref": "PR01"} + ) + partner_project = Partner.create( + {"name": "Test Project", "is_company": True, "ref": "PR02"} + ) + relation_type = RelationType.create( + { + "name": "project has participant", + "name_inverse": "participates in project", + "contact_type_left": "c", + "contact_type_right": "p", + "allow_function": False, + } + ) + relation_vals = { + "left_partner_id": partner_project.id, + "type_id": relation_type.id, + "function": "coordinator", + "right_partner_id": partner_person.id, + } + with self.assertRaises(ValidationError): + # We do not allow a function yet. + relation_with_function = Relation.create(relation_vals) + # Now do allow function. + relation_type.write({"allow_function": True}) + relation_with_function = Relation.create(relation_vals) + self.assertTrue(relation_with_function) diff --git a/partner_multi_relation_function/views/res_partner_relation_all_views.xml b/partner_multi_relation_function/views/res_partner_relation_all_views.xml deleted file mode 100644 index 612b1f46507..00000000000 --- a/partner_multi_relation_function/views/res_partner_relation_all_views.xml +++ /dev/null @@ -1,35 +0,0 @@ - - - - - res.partner.relation.all - - - - - - - - - - - res.partner.relation.all - - - - - - - - - diff --git a/partner_multi_relation_function/views/res_partner_relation_views.xml b/partner_multi_relation_function/views/res_partner_relation_views.xml new file mode 100644 index 00000000000..3fc149b2d88 --- /dev/null +++ b/partner_multi_relation_function/views/res_partner_relation_views.xml @@ -0,0 +1,55 @@ + + + + + res.partner.relation + + + + + + + + + + + res.partner.relation + + + + + + + + + + res.partner.relation + + + + + + + + + + diff --git a/setup/partner_multi_relation_contact/odoo/addons/partner_multi_relation_contact b/setup/partner_multi_relation_contact/odoo/addons/partner_multi_relation_contact new file mode 120000 index 00000000000..d987e7cc6a3 --- /dev/null +++ b/setup/partner_multi_relation_contact/odoo/addons/partner_multi_relation_contact @@ -0,0 +1 @@ +../../../../partner_multi_relation_contact \ No newline at end of file diff --git a/setup/partner_multi_relation_contact/setup.py b/setup/partner_multi_relation_contact/setup.py new file mode 100644 index 00000000000..28c57bb6403 --- /dev/null +++ b/setup/partner_multi_relation_contact/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)