[18.0][IMP] website_membership_group: unique pages per group + new website_membership_snippet#138
Conversation
| @http.route( | ||
| [ | ||
| """/members/group/<model("membership.group","[('is_published', '=', True)]"):membership_group>""" | ||
| """/members/group/<model("membership.group"):membership_group>""" |
There was a problem hiding this comment.
This is needed for proper URL completion in the editor
| membership_group_sudo._create_membership_page() | ||
|
|
||
| # Sync page publication status with group | ||
| if membership_group_sudo.page_id.is_published != membership_group_sudo.is_published: |
There was a problem hiding this comment.
This should be an related field, or inverse / compute, or in write method, in the model.
| @@ -0,0 +1,19 @@ | |||
| <?xml version="1.0" encoding="utf-8"?> | |||
| <odoo noupdate="1"> | |||
| <record id="demo_membership_group_board" model="membership.group"> | |||
There was a problem hiding this comment.
It's better to use existing, or add more demo data to the module that introduces the model (membership_group)
| <?xml version="1.0" encoding="utf-8"?> | ||
| <odoo noupdate="1"> | ||
| <!-- Board of Directors Members --> | ||
| <record id="demo_partner_board_president" model="res.partner"> |
There was a problem hiding this comment.
Same here, also demo data should be placed in demo directory for consistency.
| return page | ||
|
|
||
| def _get_page_arch(self): | ||
| """Return the page architecture with unique IDs for this group.""" |
There was a problem hiding this comment.
You should just use a template for this
| return request.redirect(membership_group_sudo.page_id.url) | ||
| # Ensure a unique page exists for this group | ||
| if not membership_group_sudo.page_id: | ||
| membership_group_sudo._create_membership_page() |
There was a problem hiding this comment.
Should be done in the model itself on write / create.
| import publicWidget from "@web/legacy/js/public/public_widget"; | ||
| import { rpc } from "@web/core/network/rpc"; | ||
|
|
||
| publicWidget.registry.MembershipSnippet = publicWidget.Widget.extend({ |
There was a problem hiding this comment.
You can probably use DynamicSnippet for this snippet, this will take care of a lot of stuff for you
There was a problem hiding this comment.
Pull request overview
This PR extends the website membership features by generating a dedicated website.page per membership group (isolating editable structures per group) and introduces a new reusable “Members” website snippet that can display group members on any page via JSON-RPC endpoints.
Changes:
- Generate and manage unique
website.page/ir.ui.viewper published membership group, with isolatedoe_structureIDs. - Add snippet options + frontend behavior for membership group page layout/visibility customization.
- Add new
website_membership_snippetmodule with a configurable Members snippet, JSON endpoints, and HttpCase tests.
Reviewed changes
Copilot reviewed 23 out of 24 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| website_share_snapchat/static/src/snippets/s_share/000.esm.js | Removes custom _blank popup logic for share links. |
| website_membership_snippet/views/snippets/snippets.xml | Adds the “Members” snippet, registers it, and defines snippet options. |
| website_membership_snippet/tests/test_membership_snippet.py | Adds HttpCase tests for the snippet JSON endpoints. |
| website_membership_snippet/tests/init.py | Loads the snippet test module. |
| website_membership_snippet/static/src/js/membership_snippet_options.js | Adds dynamic population of membership group options in the editor. |
| website_membership_snippet/static/src/js/membership_snippet.js | Implements frontend widget to fetch/render members for the selected group. |
| website_membership_snippet/static/src/img/snippets_thumbs/s_dynamic_member_list.svg | Adds a thumbnail asset for the snippet. |
| website_membership_snippet/controllers/main.py | Adds public JSON endpoints for listing groups and members. |
| website_membership_snippet/controllers/init.py | Exposes controllers package. |
| website_membership_snippet/manifest.py | Declares new addon, dependencies, views, and assets. |
| website_membership_snippet/init.py | Initializes the addon Python package. |
| website_membership_group/views/snippets/snippet_options.xml | Adds website editor options for group page visibility/layout settings. |
| website_membership_group/tests/test_membership_group_page.py | Adds tests for auto page creation, uniqueness, and access behavior. |
| website_membership_group/tests/init.py | Loads the membership group page test module. |
| website_membership_group/templates/website.xml | Removes shared group page template and keeps member list template. |
| website_membership_group/static/src/scss/membership_group.scss | Adds styles for layout/visibility toggles and collapsible descriptions. |
| website_membership_group/static/src/js/membership_group_options.esm.js | Persists editor option classes to DB fields for per-group rendering. |
| website_membership_group/static/src/js/membership_group_frontend.esm.js | Adds frontend behavior for collapsible member descriptions. |
| website_membership_group/security/website_membership_group_security.xml | Adds an ir.rule restricting public/portal access to published groups. |
| website_membership_group/models/membership_group.py | Adds fields for snippet classes, SEO metadata, and generates per-group pages/views. |
| website_membership_group/data/governance_partners_demo.xml | Adds demo partners and their membership group relations. |
| website_membership_group/data/governance_demo.xml | Adds demo membership groups. |
| website_membership_group/controllers/main.py | Updates routing/rendering to use the generated per-group page view key. |
| website_membership_group/manifest.py | Bumps version and registers new data, demos, and assets. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| for member in members.filtered("website_published"): | ||
| result.append({ | ||
| "id": member.id, | ||
| "name": member.name, | ||
| "description": member.website_description or "", | ||
| "image": f"/web/image/res.partner/{member.id}/image_128" if member.image_128 else "", | ||
| "url": f"/members/{request.env['ir.http']._slug(member)}" if member.website_published else "", |
| let html = ""; | ||
|
|
||
| if (layout === "list") { | ||
| html = this._renderList(members); | ||
| } else if (layout === "avatars") { | ||
| html = this._renderAvatars(members); | ||
| } else { | ||
| html = this._renderGrid(members); | ||
| } | ||
|
|
||
| container.innerHTML = html; | ||
| this.el.classList.remove("o_snippet_empty"); | ||
| }, | ||
|
|
||
| _renderGrid(members) { | ||
| return `<div class="row g-3">${members.map(m => ` | ||
| <div class="col-md-4 col-lg-3"> | ||
| <div class="card h-100 shadow-sm"> | ||
| <div class="card-body text-center"> | ||
| <img class="rounded-circle mb-3" | ||
| style="width: 80px; height: 80px; object-fit: cover;" | ||
| src="${m.image || "/web/static/img/user_placeholder.jpg"}" | ||
| alt="${m.name}"/> | ||
| <h5 class="card-title">${m.name}</h5> | ||
| ${m.description ? `<p class="card-text text-muted small">${m.description}</p>` : ""} | ||
| ${m.url ? `<a href="${m.url}" class="btn btn-sm btn-primary">View Profile</a>` : ""} | ||
| </div> | ||
| </div> | ||
| </div> | ||
| `).join("")}</div>`; | ||
| }, | ||
|
|
||
| _renderList(members) { | ||
| return `<div class="row">${members.map(m => ` | ||
| <div class="col-12 mb-3"> | ||
| <div class="d-flex align-items-center gap-3 p-3 bg-light rounded"> | ||
| <img class="rounded-circle" | ||
| style="width: 60px; height: 60px; object-fit: cover;" | ||
| src="${m.image || "/web/static/img/user_placeholder.jpg"}" | ||
| alt="${m.name}"/> | ||
| <div class="flex-grow-1"> | ||
| <h6 class="mb-0">${m.name}</h6> | ||
| ${m.description ? `<small class="text-muted">${m.description}</small>` : ""} | ||
| </div> | ||
| ${m.url ? `<a href="${m.url}" class="btn btn-sm btn-outline-primary">View</a>` : ""} | ||
| </div> | ||
| </div> | ||
| `).join("")}</div>`; | ||
| }, | ||
|
|
||
| _renderAvatars(members) { | ||
| return `<div class="d-flex flex-wrap justify-content-center gap-3">${members.map(m => ` | ||
| <div class="text-center p-2"> | ||
| <img class="rounded-circle mb-2" | ||
| style="width: 50px; height: 50px; object-fit: cover;" | ||
| src="${m.image || "/web/static/img/user_placeholder.jpg"}" | ||
| alt="${m.name}"/> | ||
| <p class="small mb-0">${m.name}</p> | ||
| </div> | ||
| `).join("")}</div>`; |
| def test_snippet_members_unpublished_group(self): | ||
| """Unpublished groups are not returned by the groups endpoint.""" | ||
| self.group.is_published = False | ||
| result = self.url_open( | ||
| "/membership/snippet/groups", | ||
| data='{"jsonrpc": "2.0", "method": "call", "params": {}, "id": 1}', | ||
| headers={"Content-Type": "application/json"}, | ||
| ) | ||
| data = result.json() | ||
| group_names = [g["name"] for g in data["result"]] | ||
| self.assertNotIn("Test Group", group_names) | ||
|
|
||
| def test_snippet_members_unpublished_partner(self): | ||
| """Unpublished partners are not included in member results.""" | ||
| self.partner.website_published = False | ||
| result = self.url_open( |
| """Auto-create page when published.""" | ||
| res = super().write(vals) | ||
| if vals.get("is_published"): | ||
| for group in self: | ||
| if not group.page_id: | ||
| group._create_membership_page() |
| website_url = fields.Char(compute="_compute_website_url", store=True) | ||
| website_snippet_wrap_classes = fields.Char( | ||
| string="Wrap Classes", | ||
| default="mg_wrap mg_show_image mg_show_top mg_show_bottom mg_show_committee mg_show_team", |
| const $row = this.$target.closest('#mg_members_row'); | ||
| const id = parseInt($row[0]?.dataset.oeId, 10); | ||
| const classes = this.$target[0].className.split(/\s+/) | ||
| .filter(cls => cls.startsWith('mg_') || cls.startsWith('col-')) |
| <xpath expr="//t[@id='installed_snippets_hook']" position="after"> | ||
| <t t-snippet="website_membership_snippet.s_membership_members" | ||
| string="Members" | ||
| t-thumbnail="/website/static/src/img/snippets_thumbs/s_company_team.svg"/> |
| group = request.env["membership.group"].sudo().browse(int(group_id)) | ||
| if not group.exists(): | ||
| return [] | ||
|
|
||
| members = group.membership_group_member_ids.filtered( | ||
| lambda m: m.state == "current" | ||
| ).mapped("partner_id") |
…e membership group page Updates
…o data + tests - Each membership group now gets a unique website.page with isolated oe_structure IDs, preventing content from leaking across group pages. - Auto-creates page on first publish; syncs publish status with group. - Added public ir.rule restricting non-published groups. - Fixed t-field on website_description for inline editing. - Removed dead shared template to eliminate DRY violation. - Added governance demo data (4 groups, 16 members). - Added controller tests for page creation and access control. - Improved exception handling in controller (log unexpected errors).
- New snippet allowing editors to display membership group members on any website page with selectable group and layout (grid/list/avatars). - Dynamic options panel populated from published groups via JSON endpoint. - Frontend widget loads members via JSON-RPC with three layout modes. - Added controller tests for group and member endpoints. - Uses existing s_company_team.svg as snippet thumbnail.
Fixes from code review: - Restored domain filter in route for editor URL completion - Moved page creation and publish sync from controller to model write() - Moved demo data to base membership_group module - Created QWeb template membership_group_page_base (eliminated inline string) - Sync page publish status on both publish and unpublish transitions - Removed mg_wrap from default to avoid class duplication - Filter out structural mg_*_col classes in MemberColOpts - Added e2e test suite with Playwright
b8864db to
9958ed2
Compare
There was a problem hiding this comment.
I cannot create / publish the page
I get this error when drag-dropping the member group snippet:
2026-05-05 13:52:35,314 171073 ERROR 18-members-group-pages-abdel-3 odoo.http: Exception during request handling.
Traceback (most recent call last):
File "/home/tarteo/dev/addons-curq-18/.repos/odoo/odoo/http.py", line 2577, in __call__
response = request._serve_db()
^^^^^^^^^^^^^^^^^^^
File "/home/tarteo/dev/addons-curq-18/.repos/odoo/odoo/http.py", line 2104, in _serve_db
return self._transactioning(
^^^^^^^^^^^^^^^^^^^^^
File "/home/tarteo/dev/addons-curq-18/.repos/odoo/odoo/http.py", line 2167, in _transactioning
return service_model.retrying(func, env=self.env)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/tarteo/dev/addons-curq-18/.repos/odoo/odoo/service/model.py", line 157, in retrying
result = func()
^^^^^^
File "/home/tarteo/dev/addons-curq-18/.repos/odoo/odoo/http.py", line 2134, in _serve_ir_http
response = self.dispatcher.dispatch(rule.endpoint, args)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/tarteo/dev/addons-curq-18/.repos/odoo/odoo/http.py", line 2382, in dispatch
result = self.request.registry['ir.http']._dispatch(endpoint)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/tarteo/dev/addons-curq-18/.repos/odoo/odoo/addons/base/models/ir_http.py", line 333, in _dispatch
result = endpoint(**request.params)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/tarteo/dev/addons-curq-18/.repos/odoo/odoo/http.py", line 754, in route_wrapper
result = endpoint(self, *args, **params_ok)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/tarteo/dev/addons-curq-18/.repos/odoo/addons/website/controllers/main.py", line 421, in get_dynamic_filter
return dynamic_filter and dynamic_filter._render(template_key, limit, search_domain, with_sample, **custom_template_data) or []
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/tarteo/dev/addons-curq-18/.repos/odoo/addons/website/models/website_snippet_filter.py", line 72, in _render
records = self._prepare_sample(limit)
^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/tarteo/dev/addons-curq-18/.repos/odoo/addons/website/models/website_snippet_filter.py", line 169, in _prepare_sample
return self._filter_records_to_values(records, is_sample=True)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/tarteo/dev/addons-curq-18/.repos/odoo/addons/website_sale/models/website_snippet_filter.py", line 95, in _filter_records_to_values
res_products = super()._filter_records_to_values(records, is_sample)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/tarteo/dev/addons-curq-18/.repos/odoo/addons/website/models/website_snippet_filter.py", line 244, in _filter_records_to_values
data[field_name] = record[field_name].decode('utf8') if field_name in record else '/web/image'
^^^^^^^^^^^^^^^^^^^^^^^^^
AttributeError: 'bool' object has no attribute 'decode'
| <?xml version="1.0" encoding="utf-8"?> | ||
| <odoo noupdate="1"> | ||
| <!-- Set website-specific fields on demo groups --> | ||
| <record id="membership_group.demo_membership_group_board" model="membership.group"> |
There was a problem hiding this comment.
2026-05-05 13:16:46,250 151280 WARNING 18-members-group-pages-abdel odoo.modules.loading: Module website_membership_group demo data failed to install, installed without demo data
Traceback (most recent call last):
File "/home/tarteo/dev/addons-curq-18/.repos/odoo/odoo/tools/convert.py", line 590, in _tag_root
f(rec)
File "/home/tarteo/dev/addons-curq-18/.repos/odoo/odoo/tools/convert.py", line 374, in _tag_record
raise Exception("Cannot update missing record %r" % xid)
Exception: Cannot update missing record 'membership_group.demo_membership_group_board'
The above exception was the direct cause of the following exception:
Traceback (most recent call last):
File "/home/tarteo/dev/addons-curq-18/.repos/odoo/odoo/modules/loading.py", line 90, in load_demo
load_data(env(su=True), idref, mode, kind='demo', package=package)
File "/home/tarteo/dev/addons-curq-18/.repos/odoo/odoo/modules/loading.py", line 72, in load_data
tools.convert_file(env, package.name, filename, idref, mode, noupdate, kind)
File "/home/tarteo/dev/addons-curq-18/.repos/odoo/odoo/tools/convert.py", line 662, in convert_file
convert_xml_import(env, module, fp, idref, mode, noupdate)
File "/home/tarteo/dev/addons-curq-18/.repos/odoo/odoo/tools/convert.py", line 733, in convert_xml_import
obj.parse(doc.getroot())
File "/home/tarteo/dev/addons-curq-18/.repos/odoo/odoo/tools/convert.py", line 648, in parse
self._tag_root(de)
File "/home/tarteo/dev/addons-curq-18/.repos/odoo/odoo/tools/convert.py", line 603, in _tag_root
raise ParseError('while parsing %s:%s, somewhere inside\n%s' % (
odoo.tools.convert.ParseError: while parsing /home/tarteo/dev/addons-curq-18/website_membership_group/demo/website_membership_group_demo.xml:4, somewhere inside
<record id="membership_group.demo_membership_group_board" model="membership.group">
<field name="is_published" eval="True"/>
</record>
| <field name="voting_group" eval="True" /> | ||
| </record> | ||
|
|
||
| <record id="demo_membership_group_board" model="membership.group"> |
There was a problem hiding this comment.
2026-05-05 13:22:31,204 153951 WARNING 18-members-group-pages-abdel-2 odoo.modules.loading: Module membership_group demo data failed to install, installed without demo data
Traceback (most recent call last):
File "/home/tarteo/dev/addons-curq-18/.repos/odoo/odoo/modules/loading.py", line 90, in load_demo
load_data(env(su=True), idref, mode, kind='demo', package=package)
File "/home/tarteo/dev/addons-curq-18/.repos/odoo/odoo/modules/loading.py", line 72, in load_data
tools.convert_file(env, package.name, filename, idref, mode, noupdate, kind)
File "/home/tarteo/dev/addons-curq-18/.repos/odoo/odoo/tools/convert.py", line 662, in convert_file
convert_xml_import(env, module, fp, idref, mode, noupdate)
File "/home/tarteo/dev/addons-curq-18/.repos/odoo/odoo/tools/convert.py", line 733, in convert_xml_import
obj.parse(doc.getroot())
File "/home/tarteo/dev/addons-curq-18/.repos/odoo/odoo/tools/convert.py", line 648, in parse
self._tag_root(de)
File "/home/tarteo/dev/addons-curq-18/.repos/odoo/odoo/tools/convert.py", line 601, in _tag_root
raise ParseError(msg) from None # Restart with "--log-handler odoo.tools.convert:DEBUG" for complete traceback
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
odoo.tools.convert.ParseError: while parsing /home/tarteo/dev/addons-curq-18/membership_group/data/membership_group_demo.xml:25
Only one voting group is allowed
View error context:
'-no context-'
… CI failures
Fixes for reviewer feedback (tarteo):
1. controllers/main.py:19 — Restored domain filter in route converter
for proper URL completion in website editor.
2. controllers/main.py:38-41 — Moved page creation and publish sync
from controller to model write() method.
3. demo/website_membership_group_demo.xml — Moved demo data from
website_membership_group to membership_group module, keeping only
website-specific fields (is_published, website_description).
4. models/membership_group.py:74-77 — Eliminated inline Python string
template (DRY violation). Now uses QWeb template
membership_group_page_base and returns view.arch.
5. models/membership_group.py:79-91 — write() now syncs page_id.is_published
on both publish AND unpublish transitions. Remove mg_wrap from
default to avoid class duplication.
6. membership_group_options.esm.js:108 — Filter out structural
mg_committee_col / mg_team_col classes from MemberColOpts
persistence to prevent duplication.
7. website_membership_snippet — Rewrote to use Odoo's DynamicSnippet
architecture (website.snippet.filter + QWeb templates) instead
of custom JSON endpoints and manual rendering.
8. Fixed XSS risks in snippet JS by switching to DynamicSnippet
which renders via properly escaped QWeb templates.
9. membership_group_demo.xml:27 — Fixed 'Only one voting group
is allowed' constraint violation. Removed voting_group=True
from Board of Directors (only demo_membership_vote_group
should be a voting group).
CI fixes (pre-commit):
10. Ran black on all Python files (7 files reformatted).
11. Ran isort to fix import ordering.
12. Fixed flake8 F841 (unused variable) in test_membership_snippet.py.
13. Fixed eslint: renamed JS files to .esm.js extension; added
/* global MutationObserver */ to membership_group_options.esm.js.
14. Removed test_e2e_membership.py from Odoo test directory
(standalone Playwright script, not an Odoo unit test).
Testing:
- All 9 Odoo unit tests pass (EXIT_CODE=0)
- All 7 Playwright e2e tests pass
- Clean DB install verified: no constraint violation, demo loads
AI and LLM Use Disclosure
I have used Opencode with Kimi K2.6 to create the changes using https://github.com/fhidalgodev/odoo-development-skill to follow Odoo and OCA best practices.
Summary
This PR builds on and extends #131, addressing the remaining requirements that content added to one membership group page must not appear on others.
What was changed from #131
website_membership_group
website.pagewith isolatedoe_structureIDs (oe_structure_mg_{id}_1,_2,_3,_4). Content added to one group's page never leaks to another.ir.rulerestricting public/portal users to published groups only.website_descriptionnow usest-fieldfor proper inline editing.TestMembershipGroupPage(3 tests).website_membership_snippet (new module)
website_publishedflag.TestMembershipSnippet(4 tests).Testing
HttpCase,post_install)./membership/snippet/groupsand/membership/snippet/members.