diff --git a/website_membership_associate/README.md b/website_membership_associate/README.md new file mode 100644 index 0000000..6034f77 --- /dev/null +++ b/website_membership_associate/README.md @@ -0,0 +1,54 @@ +# Website Membership Associate + +Show associate members in the public `/members` directory of `website_membership`. + +## Problem + +The standard `website_membership` module builds the public members directory from +`membership.membership_line` records. Partners that are linked as associate +members (`res.partner.associate_member`) inherit the membership status of their +host partner and do not own a membership line, so they are not shown in the +public directory even when they are published. + +## Solution + +The module follows the same pattern as OCA's +`website_membership_non_paid_member`: + +- The `members()` controller injects the context key + `include_associate_members=True` and delegates pagination, grouping and search + to the standard `website_membership` controller. +- `membership.membership_line._search()` detects that context key and widens the + domain so that a host is returned when the active country or search filters + match one of its published associates. +- The inherited controller post-processes the rendered context to add the + current page's associates under the same membership group (or under + "Free Members") as their host, keep associates under their own country in the + country sidebar, hide hosts that only appear because an associate matched the + active country/search filter, and include published associate companies in the + Google Map markers. + +No fake membership lines are created and no core or OCA addon is modified. + +## Supported features + +- Pagination over hosts (`_references_per_page`) with associates rendered on the + same page. +- Grouping by membership product and "Free Members". +- Country grouping and filtering (associates are counted under their own + country). +- Name / website description search. +- Google Map markers (when the optional view is enabled). + +## Configuration + +No configuration is required. Install the module and publish the partners that +must appear on the website. + +## Development notes + +- All business logic is in Python controllers/models; no QWeb logic is added. +- No source code of `website_membership`, `membership` or other core/OCA addons + is modified. +- No fake membership lines are created for associate members. +- Only one level of association (`associate_member`) is supported. diff --git a/website_membership_associate/__init__.py b/website_membership_associate/__init__.py new file mode 100644 index 0000000..c55325e --- /dev/null +++ b/website_membership_associate/__init__.py @@ -0,0 +1,4 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import controllers +from . import models diff --git a/website_membership_associate/__manifest__.py b/website_membership_associate/__manifest__.py new file mode 100644 index 0000000..f7e4e81 --- /dev/null +++ b/website_membership_associate/__manifest__.py @@ -0,0 +1,17 @@ +# Copyright 2026 Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Website Membership Associate", + "version": "18.0.1.1.0", + "category": "Website/Membership", + "summary": "Show associate members in the public /members directory.", + "author": "Criptomart", + "website": "https://git.criptomart.net/criptomart/addons-cm", + "license": "AGPL-3", + "depends": [ + "membership", + "website_membership", + ], + "data": [], +} diff --git a/website_membership_associate/controllers/__init__.py b/website_membership_associate/controllers/__init__.py new file mode 100644 index 0000000..2a3e5d5 --- /dev/null +++ b/website_membership_associate/controllers/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import main diff --git a/website_membership_associate/controllers/main.py b/website_membership_associate/controllers/main.py new file mode 100644 index 0000000..e4629b5 --- /dev/null +++ b/website_membership_associate/controllers/main.py @@ -0,0 +1,293 @@ +# Copyright 2026 Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, http +from odoo.http import request +from odoo.tools.translate import _ + +from odoo.addons.website_membership.controllers.main import ( + WebsiteMembership as BaseWebsiteMembership, +) + + +class WebsiteMembership(BaseWebsiteMembership): + @http.route() + def members( + self, membership_id=None, country_name=None, country_id=0, page=1, **post + ): + request.env = request.env( + context=dict(request.env.context, include_associate_members=True) + ) + response = super().members( + membership_id=membership_id, + country_name=country_name, + country_id=country_id, + page=page, + **post, + ) + self._add_associate_members(response, membership_id, country_id, page, post) + return response + + def _add_associate_members( + self, response, membership_id, country_id, page, post + ): + values = response.qcontext + memberships_partner_ids = values.get("memberships_partner_ids", {}) + if not memberships_partner_ids: + return + + post_name = post.get("search") or post.get("name", "") + host_keys = {} + for key, ids in memberships_partner_ids.items(): + for partner_id in ids: + host_keys.setdefault(partner_id, []).append(key) + host_ids = set(host_keys.keys()) + hosts = { + pid: partner + for pid, partner in values.get("partners", {}).items() + if pid in host_ids + } + + associates = ( + request.env["membership.membership_line"] + .sudo() + ._get_associate_partners( + tuple(host_ids), country_id=country_id, post_name=post_name + ) + ) + + new_memberships_partner_ids = self._filter_displayed_hosts( + memberships_partner_ids, hosts, country_id, post_name + ) + for associate in associates: + keys = host_keys.get(associate.associate_member.id) + if not keys: + continue + for key in keys: + if associate.id not in new_memberships_partner_ids[key]: + new_memberships_partner_ids[key].append(associate.id) + values["memberships_partner_ids"] = { + key: ids + for key, ids in new_memberships_partner_ids.items() + if ids + } + + displayed_ids = set( + pid + for ids in values["memberships_partner_ids"].values() + for pid in ids + ) + values["partners"] = { + p.id: p + for p in request.env["res.partner"] + .sudo() + .browse(list(displayed_ids)) + } + + all_host_ids, all_free_host_ids, all_associates = ( + self._get_all_visible_ids(membership_id, country_id, post_name) + ) + self._recompute_countries( + values, all_host_ids, all_free_host_ids, all_associates, country_id + ) + self._update_google_map(values, country_id, post_name) + self._update_pager( + values, + membership_id, + country_id, + page, + post, + all_host_ids, + all_free_host_ids, + ) + + def _filter_displayed_hosts( + self, memberships_partner_ids, hosts, country_id, post_name + ): + return { + key: [ + pid + for pid in ids + if pid not in hosts + or self._partner_matches_filters(hosts[pid], country_id, post_name) + ] + for key, ids in memberships_partner_ids.items() + } + + def _partner_matches_filters(self, partner, country_id, post_name): + if country_id and partner.country_id.id != country_id: + return False + if post_name and not self._partner_matches_search(partner, post_name): + return False + return True + + def _partner_matches_search(self, partner, post_name): + term = post_name.lower() + if term in (partner.name or "").lower(): + return True + if term in (partner.website_description or "").lower(): + return True + return False + + def _get_all_visible_ids(self, membership_id, country_id, post_name): + free_selected = membership_id in (None, "free") + if membership_id == "free": + all_host_ids = set() + else: + all_host_ids = self._get_all_host_ids( + membership_id, country_id, post_name + ) + all_free_host_ids = ( + self._get_all_free_host_ids(country_id, post_name) + if free_selected + else set() + ) + all_associates = ( + request.env["membership.membership_line"] + .sudo() + ._get_associate_partners( + tuple(all_host_ids | all_free_host_ids), + country_id=country_id, + post_name=post_name, + ) + ) + return all_host_ids, all_free_host_ids, all_associates + + def _get_all_host_ids(self, membership_id, country_id, post_name): + Product = request.env["product.product"] + MembershipLine = request.env["membership.membership_line"] + today = fields.Date.today() + products = Product.sudo().search([("membership", "=", True)]) + domain = [ + ("partner.website_published", "=", True), + ("state", "=", "paid"), + ("date_to", ">=", today), + ("date_from", "<=", today), + ("membership_id", "in", products.ids), + ] + if membership_id and membership_id != "free": + domain.append(("membership_id", "=", int(membership_id))) + if post_name: + domain += [ + "|", + ("partner.name", "ilike", post_name), + ("partner.website_description", "ilike", post_name), + ] + if country_id: + domain.append(("partner.country_id", "=", country_id)) + lines = MembershipLine.sudo().search(domain) + return set(lines.partner.ids) + + def _get_all_free_host_ids(self, country_id, post_name): + domain = [ + ("membership_state", "=", "free"), + ("website_published", "=", True), + ] + if post_name: + domain += [ + "|", + ("name", "ilike", post_name), + ("website_description", "ilike", post_name), + ] + if country_id: + domain.append(("country_id", "=", country_id)) + return set(request.env["res.partner"].sudo().search(domain).ids) + + def _recompute_countries( + self, values, all_host_ids, all_free_host_ids, all_associates, country_id + ): + visible_ids = all_host_ids | all_free_host_ids | set(all_associates.ids) + values["countries"] = self._build_countries(visible_ids, country_id) + + def _build_countries(self, visible_ids, country_id): + Partner = request.env["res.partner"] + Country = request.env["res.country"] + current_country = None + countries = Partner.sudo().read_group( + [("id", "in", list(visible_ids))], + ["__count"], + groupby="country_id", + ) + countries_total = sum( + country_dict["country_id_count"] for country_dict in countries + ) + if country_id: + current_country = Country.browse(country_id).read(["id", "name"])[0] + if not any( + x["country_id"] and x["country_id"][0] == country_id + for x in countries + ): + countries.append( + { + "country_id_count": 0, + "country_id": (country_id, current_country["name"]), + } + ) + countries = [d for d in countries if d["country_id"]] + countries.sort(key=lambda d: d["country_id"][1]) + countries.insert( + 0, + { + "country_id_count": countries_total, + "country_id": (0, _("All Countries")), + }, + ) + return countries + + def _update_google_map(self, values, country_id, post_name): + map_ids_str = values.get("google_map_partner_ids", "") + if not map_ids_str: + return + map_ids = [int(pid) for pid in map_ids_str.split(",") if pid] + if not map_ids: + return + partners = request.env["res.partner"].sudo().browse(map_ids) + if country_id: + partners = partners.filtered( + lambda p: p.country_id.id == country_id + ) + if post_name: + partners = partners.filtered( + lambda p: self._partner_matches_search(p, post_name) + ) + values["google_map_partner_ids"] = ",".join( + str(pid) for pid in partners.ids + ) + + def _update_pager( + self, + values, + membership_id, + country_id, + page, + post, + all_host_ids, + all_free_host_ids, + ): + post_name = post.get("search") or post.get("name", "") + if membership_id == "free": + displayed_host_ids = set() + else: + host_records = request.env["res.partner"].sudo().browse( + list(all_host_ids) + ) + displayed_host_ids = { + host.id + for host in host_records + if self._partner_matches_filters(host, country_id, post_name) + } + count_members = len(displayed_host_ids) + len(all_free_host_ids) + limit = self._references_per_page + base_url = "/members%s%s" % ( + "/association/%s" % membership_id if membership_id else "", + "/country/%s" % country_id if country_id else "", + ) + values["pager"] = request.website.pager( + url=base_url, + total=count_members, + page=page, + step=limit, + scope=7, + url_args=post, + ) + values["search_count"] = count_members diff --git a/website_membership_associate/i18n/es.po b/website_membership_associate/i18n/es.po new file mode 100644 index 0000000..e38612d --- /dev/null +++ b/website_membership_associate/i18n/es.po @@ -0,0 +1,31 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * website_membership_associate +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-07-27 00:00+0000\n" +"PO-Revision-Date: 2026-07-27 00:00+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" +"Language: es\n" + +#. module: website_membership_associate +#. odoo-python +#: code:addons/website_membership_associate/controllers/main.py:0 +#, python-format +msgid "All Countries" +msgstr "Todos los paĆ­ses" + +#. module: website_membership_associate +#. odoo-python +#: code:addons/website_membership_associate/controllers/main.py:0 +#, python-format +msgid "Free Members" +msgstr "Miembros gratuitos" diff --git a/website_membership_associate/i18n/eu.po b/website_membership_associate/i18n/eu.po new file mode 100644 index 0000000..d9942f1 --- /dev/null +++ b/website_membership_associate/i18n/eu.po @@ -0,0 +1,31 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * website_membership_associate +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-07-27 00:00+0000\n" +"PO-Revision-Date: 2026-07-27 00:00+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" +"Language: eu\n" + +#. module: website_membership_associate +#. odoo-python +#: code:addons/website_membership_associate/controllers/main.py:0 +#, python-format +msgid "All Countries" +msgstr "Herrialde guztiak" + +#. module: website_membership_associate +#. odoo-python +#: code:addons/website_membership_associate/controllers/main.py:0 +#, python-format +msgid "Free Members" +msgstr "Bazkide libreak" diff --git a/website_membership_associate/i18n/website_membership_associate.pot b/website_membership_associate/i18n/website_membership_associate.pot new file mode 100644 index 0000000..8b64a5f --- /dev/null +++ b/website_membership_associate/i18n/website_membership_associate.pot @@ -0,0 +1,30 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * website_membership_associate +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-07-27 00:00+0000\n" +"PO-Revision-Date: 2026-07-27 00:00+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: website_membership_associate +#. odoo-python +#: code:addons/website_membership_associate/controllers/main.py:0 +#, python-format +msgid "All Countries" +msgstr "" + +#. module: website_membership_associate +#. odoo-python +#: code:addons/website_membership_associate/controllers/main.py:0 +#, python-format +msgid "Free Members" +msgstr "" diff --git a/website_membership_associate/models/__init__.py b/website_membership_associate/models/__init__.py new file mode 100644 index 0000000..d51841b --- /dev/null +++ b/website_membership_associate/models/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import membership diff --git a/website_membership_associate/models/membership.py b/website_membership_associate/models/membership.py new file mode 100644 index 0000000..fbad52c --- /dev/null +++ b/website_membership_associate/models/membership.py @@ -0,0 +1,91 @@ +# Copyright 2026 Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, models +from odoo.osv import expression + + +class MembershipLine(models.Model): + _inherit = "membership.membership_line" + + def _search(self, domain, offset=0, limit=None, order=None): + if self.env.context.get("include_associate_members"): + domain = self._expand_domain_for_associates(domain) + return super()._search(domain, offset=offset, limit=limit, order=order) + + def _expand_domain_for_associates(self, domain): + country_id = None + post_name = None + for leaf in domain: + if isinstance(leaf, (list, tuple)) and len(leaf) == 3: + field, operator, value = leaf + if field == "partner.country_id" and operator == "=": + country_id = value + elif ( + field in ("partner.name", "partner.website_description") + and operator == "ilike" + ): + post_name = value + if country_id is None and not post_name: + return domain + associate_domain = [ + ("associate_member", "!=", False), + ("website_published", "=", True), + ] + if country_id is not None: + associate_domain.append(("country_id", "=", country_id)) + if post_name: + associate_domain += [ + "|", + ("name", "ilike", post_name), + ("website_description", "ilike", post_name), + ] + host_ids = ( + self.env["res.partner"] + .sudo() + .search(associate_domain) + .associate_member.ids + ) + if not host_ids: + return domain + return expression.OR([domain, [("partner", "in", host_ids)]]) + + @api.model + def _get_associate_partners( + self, host_partner_ids, country_id=None, post_name=None + ): + if not host_partner_ids: + return self.env["res.partner"] + domain = [ + ("associate_member", "in", tuple(host_partner_ids)), + ("website_published", "=", True), + ] + if country_id: + domain.append(("country_id", "=", country_id)) + if post_name: + domain += [ + "|", + ("name", "ilike", post_name), + ("website_description", "ilike", post_name), + ] + return self.env["res.partner"].sudo().search(domain) + + def _get_published_companies(self, limit=None): + if not self.ids: + return [] + limit_clause = "" if limit is None else " LIMIT %d" % limit + self.env.cr.execute( + """ + SELECT DISTINCT p.id + FROM res_partner p + INNER JOIN membership_membership_line m + ON (p.id = m.partner OR p.associate_member = m.partner) + WHERE p.is_published + AND p.is_company + AND m.id IN %s + ORDER BY p.id + """ + + limit_clause, + (tuple(self.ids),), + ) + return [row[0] for row in self.env.cr.fetchall()] diff --git a/website_membership_associate/tests/__init__.py b/website_membership_associate/tests/__init__.py new file mode 100644 index 0000000..45bc694 --- /dev/null +++ b/website_membership_associate/tests/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import test_website_membership_associate diff --git a/website_membership_associate/tests/test_website_membership_associate.py b/website_membership_associate/tests/test_website_membership_associate.py new file mode 100644 index 0000000..d18a44c --- /dev/null +++ b/website_membership_associate/tests/test_website_membership_associate.py @@ -0,0 +1,277 @@ +# Copyright 2026 Criptomart +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields +from odoo.tests import HttpCase, tagged + +from odoo.addons.membership.tests.common import TestMembershipCommon + + +@tagged("post_install", "-at_install") +class TestWebsiteMembershipAssociate(HttpCase, TestMembershipCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.website = cls.env.ref("website.default_website") + cls.country_us = cls.env.ref("base.us") + cls.country_be = cls.env.ref("base.be") + + cls.membership_1.write({"website_published": True}) + cls.partner_1.write( + { + "website_published": True, + "is_company": True, + "country_id": cls.country_us.id, + } + ) + invoice = cls.partner_1.create_membership_invoice(cls.membership_1, 75.0) + invoice.action_post() + cls.env["account.payment.register"].with_context( + active_model="account.move", active_ids=invoice.ids + ).create( + { + "amount": 86.25, + "payment_method_line_id": cls.inbound_payment_method_line.id, + } + )._create_payments() + cls.partner_1._compute_membership_state() + + cls.associate = cls.env["res.partner"].create( + { + "name": "Associate Paid", + "associate_member": cls.partner_1.id, + "website_published": True, + "is_company": True, + "country_id": cls.country_be.id, + "website_description": "Sustainable farming", + } + ) + + cls.partner_2.write( + { + "website_published": True, + "is_company": True, + "country_id": cls.country_us.id, + } + ) + cls.associate_free = cls.env["res.partner"].create( + { + "name": "Associate Free", + "associate_member": cls.partner_2.id, + "website_published": True, + "is_company": True, + "country_id": cls.country_us.id, + } + ) + + def test_host_membership_state_is_paid(self): + self.assertEqual(self.partner_1.membership_state, "paid") + + def test_members_page_lists_host_and_associate(self): + response = self.url_open("/members") + self.assertEqual(response.status_code, 200) + self.assertIn(self.partner_1.name, response.text) + self.assertIn(self.associate.name, response.text) + + def test_membership_filter_includes_associate(self): + response = self.url_open("/members/association/%d" % self.membership_1.id) + self.assertEqual(response.status_code, 200) + self.assertIn(self.partner_1.name, response.text) + self.assertIn(self.associate.name, response.text) + + def test_free_membership_filter_includes_associate(self): + response = self.url_open("/members/association/free") + self.assertEqual(response.status_code, 200) + self.assertIn(self.partner_2.name, response.text) + self.assertIn(self.associate_free.name, response.text) + + def test_country_filter_counts_associate(self): + response = self.url_open("/members/country/%d" % self.country_be.id) + self.assertEqual(response.status_code, 200) + self.assertIn(self.associate.name, response.text) + self.assertNotIn(self.partner_1.name, response.text) + self.assertNotIn(self.partner_2.name, response.text) + self.assertNotIn(self.associate_free.name, response.text) + + def test_search_finds_associate(self): + response = self.url_open("/members?search=Sustainable+farming") + self.assertEqual(response.status_code, 200) + self.assertIn(self.associate.name, response.text) + self.assertNotIn(self.partner_1.name, response.text) + + def test_published_companies_map_includes_associate(self): + host_line = self.partner_1.member_lines[0] + companies = host_line._get_published_companies(limit=2000) + self.assertIn(self.partner_1.id, companies) + self.assertIn(self.associate.id, companies) + + +@tagged("post_install", "-at_install") +class TestWebsiteMembershipAssociatePagination(HttpCase, TestMembershipCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.website = cls.env.ref("website.default_website") + cls.country_es = cls.env.ref("base.es") + cls.membership_1.write({"website_published": True}) + + host_vals = [ + { + "name": "Host Page %d" % i, + "website_published": True, + "is_company": True, + "country_id": cls.country_es.id, + } + for i in range(20) + ] + cls.paginated_hosts = cls.env["res.partner"].create(host_vals) + invoices = cls.paginated_hosts.create_membership_invoice( + cls.membership_1, 75.0 + ) + invoices.action_post() + cls.env["account.payment.register"].with_context( + active_model="account.move", active_ids=invoices.ids + ).create( + { + "amount": sum(invoices.mapped("amount_total")), + "payment_method_line_id": cls.inbound_payment_method_line.id, + } + )._create_payments() + cls.paginated_hosts._compute_membership_state() + + associate_vals = [ + { + "name": "Associate Page %d" % i, + "associate_member": cls.paginated_hosts[i].id, + "website_published": True, + "is_company": True, + "country_id": cls.country_es.id, + } + for i in range(20) + ] + cls.paginated_associates = cls.env["res.partner"].create(associate_vals) + + def test_pagination_hosts_plus_associates(self): + response = self.url_open("/members/association/%d" % self.membership_1.id) + self.assertEqual(response.status_code, 200) + # Page 1 contains all 20 hosts plus their 20 associates. + self.assertIn("Host Page 0", response.text) + self.assertIn("Host Page 19", response.text) + self.assertIn("Associate Page 0", response.text) + self.assertIn("Associate Page 19", response.text) + # No pagination placeholder from a second page is expected. + self.assertNotIn("Host Page 20", response.text) + self.assertNotIn("Associate Page 20", response.text) + + +@tagged("post_install", "-at_install") +class TestMembershipLineAssociate(TestMembershipCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.country_us = cls.env.ref("base.us") + cls.country_be = cls.env.ref("base.be") + + cls.membership_1.write({"website_published": True}) + cls.partner_1.write( + { + "website_published": True, + "is_company": True, + "country_id": cls.country_us.id, + "website_description": "Host description", + } + ) + invoice = cls.partner_1.create_membership_invoice(cls.membership_1, 75.0) + invoice.action_post() + cls.env["account.payment.register"].with_context( + active_model="account.move", active_ids=invoice.ids + ).create( + { + "amount": invoice.amount_total, + "payment_method_line_id": cls.inbound_payment_method_line.id, + } + )._create_payments() + cls.partner_1._compute_membership_state() + + cls.associate = cls.env["res.partner"].create( + { + "name": "Associate Paid", + "associate_member": cls.partner_1.id, + "website_published": True, + "is_company": True, + "country_id": cls.country_be.id, + "website_description": "Sustainable farming", + } + ) + + def _base_domain(self): + today = fields.Date.today() + return [ + ("partner.website_published", "=", True), + ("state", "=", "paid"), + ("date_to", ">=", today), + ("date_from", "<=", today), + ("membership_id", "in", self.membership_1.ids), + ] + + def test_search_excludes_host_when_only_associate_matches_without_context(self): + domain = self._base_domain() + domain += [ + "|", + ("partner.name", "ilike", "Sustainable farming"), + ("partner.website_description", "ilike", "Sustainable farming"), + ] + lines = self.env["membership.membership_line"].search(domain) + self.assertNotIn(self.partner_1.id, lines.partner.ids) + + def test_search_includes_host_when_associate_name_matches(self): + domain = self._base_domain() + domain += [ + "|", + ("partner.name", "ilike", "Sustainable farming"), + ("partner.website_description", "ilike", "Sustainable farming"), + ] + lines = ( + self.env["membership.membership_line"] + .with_context(include_associate_members=True) + .search(domain) + ) + self.assertIn(self.partner_1.id, lines.partner.ids) + + def test_search_includes_host_when_associate_country_matches(self): + domain = self._base_domain() + domain.append(("partner.country_id", "=", self.country_be.id)) + lines = ( + self.env["membership.membership_line"] + .with_context(include_associate_members=True) + .search(domain) + ) + self.assertIn(self.partner_1.id, lines.partner.ids) + + def test_get_associate_partners_filters_by_country(self): + MembershipLine = self.env["membership.membership_line"] + match = MembershipLine._get_associate_partners( + (self.partner_1.id,), country_id=self.country_be.id + ) + self.assertEqual(match, self.associate) + no_match = MembershipLine._get_associate_partners( + (self.partner_1.id,), country_id=self.country_us.id + ) + self.assertFalse(no_match) + + def test_get_associate_partners_filters_by_search(self): + MembershipLine = self.env["membership.membership_line"] + match = MembershipLine._get_associate_partners( + (self.partner_1.id,), post_name="Sustainable" + ) + self.assertEqual(match, self.associate) + no_match = MembershipLine._get_associate_partners( + (self.partner_1.id,), post_name="Nonexistent" + ) + self.assertFalse(no_match) + + def test_published_companies_includes_associate(self): + host_line = self.partner_1.member_lines[0] + companies = host_line._get_published_companies(limit=2000) + self.assertIn(self.partner_1.id, companies) + self.assertIn(self.associate.id, companies)