[ADD] membership_status_in_portal: portal membership status badge

Add a generic module displaying the connected portal user's membership
status in the account sidebar, below the "Edit information" link.

The native Odoo membership label (``res.partner.membership_state`` from
the ``membership`` module) is shown as a colored badge:
- green (``text-bg-success``) for free / paid members;
- yellow (``text-bg-warning``) for waiting / invoiced members;
- red (``text-bg-danger``) otherwise.

Color, icon and label are computed in the QWeb template; no model or
field is added. Includes HttpCase tests covering member and non-member
rendering and asserting no action button is rendered.
This commit is contained in:
Stéphan Sainléger
2026-09-21 14:26:43 +02:00
parent ccdd1db968
commit 34f70b342c
7 changed files with 180 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
*.*~
*pyc

View File

@@ -0,0 +1,54 @@
==========================
membership_status_in_portal
==========================
Display the membership status of the connected user in the portal.
Description
===========
In the portal account sidebar (all ``/my`` pages, including ``/my/home``),
this module shows the native Odoo membership status of the connected user
(``res.partner.membership_state`` provided by the ``membership`` module).
No registration or renewal button is displayed: non-members only see their
status.
Installation
============
Use Odoo normal module installation procedure to install
``membership_status_in_portal``.
Known issues / Roadmap
======================
None yet.
Bug Tracker
===========
Bugs are tracked on `our issues website <https://git.elabore.coop/elabore/member-tools/issues>`_. In case of
trouble, please check there if your issue has already been
reported. If you spotted it first, help us smashing it by providing a
detailed and welcomed feedback.
Credits
=======
Contributors
------------
* Stéphan Sainléger
Funders
-------
The development of this module has been financially supported by:
* Elabore (https://elabore.coop)
Maintainer
----------
This module is maintained by Elabore.

View File

View File

@@ -0,0 +1,23 @@
# Copyright 2026 Stéphan Sainléger (Elabore)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
{
"name": "membership_status_in_portal",
"version": "18.0.1.0.0",
"author": "Elabore",
"maintainer": "Elabore",
"website": "https://git.elabore.coop/elabore/member-tools",
"license": "AGPL-3",
"category": "Website",
"summary": "Display the membership status of portal users",
"depends": [
"portal",
"membership",
],
"data": [
"views/portal_sidebar.xml",
],
"installable": True,
"auto_install": False,
"application": False,
}

View File

@@ -0,0 +1 @@
from . import test_portal_sidebar

View File

@@ -0,0 +1,66 @@
import re
from odoo.tests import HttpCase, tagged
@tagged("post_install", "-at_install")
class MembershipStatusPortalCase(HttpCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.portal_group = cls.env.ref("base.group_portal")
def _create_portal_user(self, login, **partner_vals):
partner = self.env["res.partner"].create(
{
"name": login,
"lang": self.env.lang or "en_US",
**partner_vals,
}
)
return self.env["res.users"].create(
{
"name": login,
"login": login,
"password": login,
"partner_id": partner.id,
"groups_id": [(6, 0, [self.portal_group.id])],
}
)
def _membership_label(self, partner):
selection = self.env["res.partner"].fields_get(["membership_state"])[
"membership_state"
]["selection"]
return dict(selection)[partner.membership_state]
def _open_my_home(self, user):
self.authenticate(user.login, user.password)
response = self.url_open("/my/home")
self.assertEqual(response.status_code, 200)
return response.text
def _extract_membership_block(self, html):
match = re.search(r'<div class="o_membership_state.*?</div>', html, re.DOTALL)
self.assertIsNotNone(match, "membership status block not found on /my/home")
return match.group(0)
def test_non_member_sees_status_without_button(self):
user = self._create_portal_user("nonmember")
self.assertEqual(user.partner_id.membership_state, "none")
block = self._extract_membership_block(self._open_my_home(user))
self.assertIn(self._membership_label(user.partner_id), block)
self.assertNotIn("<button", block)
self.assertNotIn("<a ", block)
def test_member_sees_status_without_button(self):
user = self._create_portal_user("member", free_member=True)
self.assertEqual(user.partner_id.membership_state, "free")
block = self._extract_membership_block(self._open_my_home(user))
self.assertIn(self._membership_label(user.partner_id), block)
self.assertNotIn("<button", block)
self.assertNotIn("<a ", block)

View File

@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<template
id="portal_side_content_membership_status"
name="Portal Sidebar: Membership Status"
inherit_id="portal.side_content"
priority="40"
>
<xpath expr="//a[@href='/my/account']" position="after">
<div class="o_membership_state mt-3">
<t t-set="state" t-value="user_id.partner_id.membership_state" />
<t
t-set="state_label"
t-value="dict(user_id.partner_id.fields_get(['membership_state'])['membership_state']['selection']).get(state)"
/>
<t
t-set="state_color"
t-value="{'free': 'success', 'paid': 'success', 'invoiced': 'warning', 'waiting': 'warning'}.get(state, 'danger')"
/>
<t
t-set="state_icon"
t-value="{'free': 'fa-check', 'paid': 'fa-check', 'invoiced': 'fa-clock-o', 'waiting': 'fa-clock-o'}.get(state, 'fa-remove')"
/>
<span
t-attf-class="badge rounded-pill text-bg-#{state_color} fs-6"
t-att-title="state_label"
>
<i t-attf-class="fa fa-fw #{state_icon}" role="img" />
<span t-out="state_label" class="ms-1" />
</span>
</div>
</xpath>
</template>
</odoo>