Skip to content

[18.0][IMP] website_membership_group: unique pages per group + new website_membership_snippet#138

Closed
superabdellekke wants to merge 5 commits into
onesteinbv:18.0from
superabdellekke:fix-share-buttons-placeholder
Closed

[18.0][IMP] website_membership_group: unique pages per group + new website_membership_snippet#138
superabdellekke wants to merge 5 commits into
onesteinbv:18.0from
superabdellekke:fix-share-buttons-placeholder

Conversation

@superabdellekke
Copy link
Copy Markdown
Contributor

@superabdellekke superabdellekke commented Apr 23, 2026

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

  • Unique pages per group: Each published membership group now gets its own website.page with isolated oe_structure IDs (oe_structure_mg_{id}_1, _2, _3, _4). Content added to one group's page never leaks to another.
  • Auto-create on publish: Pages are created automatically when a group is first published.
  • Publish status sync: Page publish status stays in sync with group publish status.
  • Security: Added ir.rule restricting public/portal users to published groups only.
  • Fix: website_description now uses t-field for proper inline editing.
  • Demo data: Added 4 governance groups + 16 demo members.
  • Tests: Added TestMembershipGroupPage (3 tests).

website_membership_snippet (new module)

  • Reusable "Members" snippet for any website page.
  • Editor selects a published membership group from a dynamically populated dropdown.
  • Three layout modes: Grid, List, Avatars.
  • Members loaded via JSON-RPC; respects website_published flag.
  • Tests: Added TestMembershipSnippet (4 tests).

Testing

  • All 7 new tests pass (HttpCase, post_install).
  • JSON endpoints verified: /membership/snippet/groups and /membership/snippet/members.

@http.route(
[
"""/members/group/<model("membership.group","[('is_published', '=', True)]"):membership_group>"""
"""/members/group/<model("membership.group"):membership_group>"""
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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">
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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">
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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."""
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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({
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can probably use DynamicSnippet for this snippet, this will take care of a lot of stuff for you

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.view per published membership group, with isolated oe_structure IDs.
  • Add snippet options + frontend behavior for membership group page layout/visibility customization.
  • Add new website_membership_snippet module 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.

Comment on lines +27 to +33
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 "",
Comment on lines +36 to +95
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>`;
Comment on lines +52 to +67
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(
Comment on lines +162 to +167
"""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"/>
Comment on lines +18 to +24
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")
ByteMeAsap and others added 4 commits May 5, 2026 15:15
…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
@tarteo tarteo force-pushed the fix-share-buttons-placeholder branch from b8864db to 9958ed2 Compare May 5, 2026 13:15
Copy link
Copy Markdown
Member

@tarteo tarteo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I cannot create / publish the page

Image Image Image

I get this error when drag-dropping the member group snippet:

Image
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">
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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">
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
@tarteo tarteo closed this May 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants