add website_membership_associate

This commit is contained in:
Luis 2026-07-28 09:29:14 +02:00
parent 40d7cc8772
commit 31d53c428c
12 changed files with 837 additions and 0 deletions

View file

@ -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.

View file

@ -0,0 +1,4 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from . import controllers
from . import models

View file

@ -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": [],
}

View file

@ -0,0 +1,3 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from . import main

View file

@ -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

View file

@ -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"

View file

@ -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"

View file

@ -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 ""

View file

@ -0,0 +1,3 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from . import membership

View file

@ -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()]

View file

@ -0,0 +1,3 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from . import test_website_membership_associate

View file

@ -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)