feat(community_membership): QR card, verification endpoint, member portal (Session 1-C)

Adds a QWeb PDF "Membership Card" report (85x54mm landscape, custom
report.paperformat) showing the company logo, member name/ID, tier, and
a QR code generated with the qrcode library (embedded as a base64 PNG via
a non-stored res.partner.membership_card_qr compute field). The QR encodes
a public verification URL built from ir.config_parameter's web.base.url.

Adds the public GET /membership/verify/<member_id> controller: shows
valid/expired/not-found with no personal data beyond name (and tier, for
valid members) - internal states like 'invoiced'/'none' are deliberately
reported as not-found so partial signup state isn't leaked.

Adds the member portal: /my/membership (status/tier/expiry), /my/membership/
renew (finds or creates a draft renewal invoice, redirects to the existing
portal invoice page), and /my/membership/card (PDF download) - plus a
"Membership" entry card on the main /my portal home page. Since
community_theme_base doesn't exist yet (that's Phase 6), the card uses
res.company.logo rather than a theme setting - still brand-neutral,
just resolved from the standard company record for now.

Verified against a live Odoo 19 + Postgres 16 container, both via the
test suite (12/12 tests: unit tests, HttpCase tests hitting the real verify
endpoint for valid/expired/unknown IDs) and by hand end-to-end - created a
tier and member over JSON-RPC, activated the membership, fetched the live
/membership/verify/ page, and rendered the actual PDF card via
/report/pdf/... (11.7KB single-page PDF, confirmed non-blank).

Along the way, hit a third real Odoo 19 API change: res.users.groups_id
was renamed to group_ids.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
metatroncubeswdev 2026-08-17 21:19:30 -04:00
parent e5d90a83d4
commit 190e51a7da
11 changed files with 291 additions and 0 deletions

View File

@ -1 +1,2 @@
from . import models
from . import controllers

View File

@ -35,13 +35,19 @@ configuration record managed by the deployment layer.
'data/ir_sequence.xml',
'data/mail_templates.xml',
'data/ir_cron.xml',
'report/membership_card_report.xml',
'views/membership_tier_views.xml',
'views/res_partner_views.xml',
'views/res_config_settings_views.xml',
'views/membership_menus.xml',
'views/portal_templates.xml',
'views/membership_verify_templates.xml',
],
'demo': [],
'images': ['static/description/banner.png'],
'application': True,
'installable': True,
'external_dependencies': {
'python': ['qrcode'],
},
}

View File

@ -0,0 +1,2 @@
from . import main
from . import portal

View File

@ -0,0 +1,26 @@
from odoo import http
from odoo.http import request
VISIBLE_STATES = {'active', 'renewal_due'}
class MembershipVerifyController(http.Controller):
@http.route(['/membership/verify/<string:member_id>'], type='http', auth='public', website=True, sitemap=False)
def membership_verify(self, member_id, **kwargs):
partner = request.env['res.partner'].sudo().search(
[('membership_member_id', '=', member_id)], limit=1
)
values = {'member_id': member_id}
if not partner:
values['status'] = 'not_found'
elif partner.membership_state == 'expired':
values.update(status='expired', name=partner.name)
elif partner.membership_state in VISIBLE_STATES:
values.update(status='valid', name=partner.name, tier=partner.membership_tier_id.name)
else:
# 'invoiced' or 'none' - not yet an active member; don't leak internal state
values['status'] = 'not_found'
return request.render('community_membership.membership_verify_page', values)

View File

@ -0,0 +1,47 @@
from odoo import http
from odoo.addons.portal.controllers.portal import CustomerPortal
from odoo.http import request
class MembershipPortal(CustomerPortal):
def _prepare_home_portal_values(self, counters):
values = super()._prepare_home_portal_values(counters)
if 'membership_count' in counters:
partner = request.env.user.partner_id
values['membership_count'] = 1 if partner.membership_state != 'none' else 0
return values
@http.route(['/my/membership'], type='http', auth='user', website=True)
def portal_my_membership(self, **kwargs):
partner = request.env.user.partner_id
return request.render('community_membership.portal_my_membership', {
'partner': partner,
'page_name': 'membership',
})
@http.route(['/my/membership/renew'], type='http', auth='user', website=True)
def portal_membership_renew(self, **kwargs):
partner = request.env.user.partner_id
invoice = request.env['account.move'].sudo().search([
('partner_id', '=', partner.id),
('move_type', '=', 'out_invoice'),
('state', '=', 'draft'),
], order='create_date desc', limit=1)
if not invoice:
invoice = partner.sudo()._create_renewal_invoice()
if not invoice:
return request.redirect('/my/membership')
return request.redirect(f'/my/invoices/{invoice.id}')
@http.route(['/my/membership/card'], type='http', auth='user', website=True)
def portal_membership_card(self, **kwargs):
partner = request.env.user.partner_id
pdf_content, _report_type = request.env['ir.actions.report'].sudo()._render_qweb_pdf(
'community_membership.action_report_membership_card', res_ids=partner.ids,
)
return request.make_response(pdf_content, headers=[
('Content-Type', 'application/pdf'),
('Content-Length', len(pdf_content)),
('Content-Disposition', 'attachment; filename=membership-card.pdf'),
])

View File

@ -1,3 +1,7 @@
import base64
import io
import qrcode
from dateutil.relativedelta import relativedelta
from odoo import api, fields, models
@ -34,12 +38,31 @@ class ResPartner(models.Model):
is_volunteer = fields.Boolean(string='Volunteer')
volunteer_hours = fields.Float(string='Volunteer Hours')
membership_org_name = fields.Char(compute='_compute_membership_org_name')
membership_card_qr = fields.Binary(compute='_compute_membership_card_qr')
def _compute_membership_org_name(self):
org_name = self.env['ir.config_parameter'].sudo().get_param('community_membership.org_name') or ''
for partner in self:
partner.membership_org_name = org_name
def _compute_membership_card_qr(self):
for partner in self:
if not partner.membership_member_id:
partner.membership_card_qr = False
continue
qr = qrcode.QRCode(box_size=8, border=2)
qr.add_data(partner._get_membership_verify_url())
qr.make(fit=True)
image = qr.make_image(fill_color='black', back_color='white')
buffer = io.BytesIO()
image.save(buffer, format='PNG')
partner.membership_card_qr = base64.b64encode(buffer.getvalue())
def _get_membership_verify_url(self):
self.ensure_one()
base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url', '')
return f"{base_url}/membership/verify/{self.membership_member_id}"
def action_activate_membership(self):
"""Transition to 'active', generating a member ID and expiry if needed."""
today = fields.Date.context_today(self)

View File

@ -0,0 +1,67 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="paperformat_membership_card" model="report.paperformat">
<field name="name">Membership Card (85x54mm)</field>
<field name="format">custom</field>
<field name="page_width">85</field>
<field name="page_height">54</field>
<field name="orientation">Landscape</field>
<field name="margin_top">2</field>
<field name="margin_bottom">2</field>
<field name="margin_left">2</field>
<field name="margin_right">2</field>
<field name="disable_shrinking" eval="True"/>
<field name="dpi">96</field>
</record>
<record id="action_report_membership_card" model="ir.actions.report">
<field name="name">Membership Card</field>
<field name="model">res.partner</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">community_membership.report_membership_card</field>
<field name="report_file">community_membership.report_membership_card</field>
<field name="paperformat_id" ref="paperformat_membership_card"/>
<field name="print_report_name">'Membership Card - %s' % (object.membership_member_id or object.name)</field>
<field name="binding_model_id" ref="model_res_partner"/>
<field name="binding_type">report</field>
</record>
<template id="report_membership_card">
<t t-call="web.basic_layout">
<t t-foreach="docs" t-as="partner">
<div style="width: 100%; height: 100%; padding: 6pt; box-sizing: border-box; font-family: monospace;
border: 1pt solid #cccccc; border-radius: 6pt;">
<table style="width: 100%; height: 100%; border-collapse: collapse;">
<tr>
<td style="width: 65%; vertical-align: top;">
<t t-if="partner.company_id.logo">
<img t-att-src="image_data_uri(partner.company_id.logo)" style="max-height: 26pt; max-width: 90%;" alt="Logo"/>
</t>
<div style="font-family: Arial, sans-serif; font-size: 9pt; font-weight: bold; margin-top: 4pt;">
<span t-out="partner.membership_org_name or partner.company_id.name or ''"/>
</div>
<div style="font-family: Arial, sans-serif; font-size: 11pt; font-weight: bold; margin-top: 6pt;">
<span t-out="partner.name"/>
</div>
<div style="font-size: 9pt; margin-top: 2pt;">
<span t-out="partner.membership_member_id or ''"/>
</div>
<div style="font-family: Arial, sans-serif; font-size: 8pt; margin-top: 4pt;">
<span t-out="partner.membership_tier_id.name or ''"/>
</div>
<div style="font-family: Arial, sans-serif; font-size: 7pt; color: #666666;">
Valid until:
<span t-out="partner.membership_expiry or 'Lifetime'"/>
</div>
</td>
<td style="width: 35%; text-align: center; vertical-align: middle;">
<img t-if="partner.membership_card_qr" t-att-src="image_data_uri(partner.membership_card_qr)"
style="width: 60pt; height: 60pt;" alt="QR"/>
</td>
</tr>
</table>
</div>
</t>
</t>
</template>
</odoo>

View File

@ -1,2 +1,3 @@
from . import test_membership
from . import test_membership_renewal
from . import test_membership_verify

View File

@ -0,0 +1,39 @@
from odoo.tests.common import HttpCase, tagged
@tagged('post_install', '-at_install')
class TestMembershipVerify(HttpCase):
def setUp(self):
super().setUp()
self.tier = self.env['community.membership.tier'].create({
'name': 'Individual',
'code': 'VERIFY-IND',
'price': 50.0,
'period': 'annual',
})
self.partner = self.env['res.partner'].create({
'name': 'Verify Tester',
'membership_tier_id': self.tier.id,
})
self.partner.action_activate_membership()
def test_verify_valid_member(self):
response = self.url_open(f'/membership/verify/{self.partner.membership_member_id}')
self.assertEqual(response.status_code, 200)
self.assertIn(b'Valid Membership', response.content)
self.assertIn(b'Verify Tester', response.content)
def test_verify_expired_member(self):
self.partner.membership_state = 'expired'
response = self.url_open(f'/membership/verify/{self.partner.membership_member_id}')
self.assertEqual(response.status_code, 200)
self.assertIn(b'Expired Membership', response.content)
def test_verify_unknown_member(self):
response = self.url_open('/membership/verify/NOT-A-REAL-ID')
self.assertEqual(response.status_code, 200)
self.assertIn(b'Not Found', response.content)
def test_qr_generated_on_activation(self):
self.assertTrue(self.partner.membership_card_qr, "Activated member should have a generated QR code")

View File

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="membership_verify_page" name="Membership Verification">
<t t-call="website.layout">
<div class="container o_membership_verify" style="max-width: 480px; margin-top: 60px; margin-bottom: 60px; text-align: center;">
<t t-if="status == 'valid'">
<div class="alert alert-success">
<h2>Valid Membership</h2>
<p t-out="name"/>
<p t-if="tier" t-out="tier"/>
</div>
</t>
<t t-elif="status == 'expired'">
<div class="alert alert-warning">
<h2>Expired Membership</h2>
<p t-out="name"/>
</div>
</t>
<t t-else="">
<div class="alert alert-danger">
<h2>Not Found</h2>
<p>No active membership found for this ID.</p>
</div>
</t>
</div>
</t>
</template>
</odoo>

View File

@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="portal_my_home_membership" name="Portal my home: Membership entry" inherit_id="portal.portal_my_home" customize_show="True" priority="20">
<xpath expr="//div[hasclass('o_portal_docs')]" position="before">
<t t-set="portal_client_category_enable" t-value="True"/>
</xpath>
<div id="portal_client_category" position="inside">
<t t-if="membership_count" t-call="portal.portal_docs_entry">
<t t-set="title">Membership</t>
<t t-set="url" t-value="'/my/membership'"/>
<t t-set="text">View your membership status, renew, or download your card</t>
<t t-set="placeholder_count" t-value="'membership_count'"/>
</t>
</div>
</template>
<template id="portal_my_membership" name="My Membership">
<t t-call="portal.portal_layout">
<t t-set="breadcrumbs_searchbar" t-value="False"/>
<div class="o_portal_my_doc_table">
<h3>My Membership</h3>
<t t-if="partner.membership_state == 'none'">
<p class="alert alert-info">You do not have an active membership yet.</p>
</t>
<t t-else="">
<table class="table">
<tr>
<td>Member ID</td>
<td><span t-out="partner.membership_member_id or '-'"/></td>
</tr>
<tr>
<td>Tier</td>
<td><span t-out="partner.membership_tier_id.name or '-'"/></td>
</tr>
<tr>
<td>Status</td>
<td><span t-out="dict(partner._fields['membership_state'].selection).get(partner.membership_state)"/></td>
</tr>
<tr>
<td>Valid Until</td>
<td><span t-out="partner.membership_expiry or 'Lifetime'"/></td>
</tr>
</table>
<a t-if="partner.membership_state in ('renewal_due', 'expired')"
href="/my/membership/renew" class="btn btn-primary">Renew</a>
<a href="/my/membership/card" class="btn btn-secondary">Download Card</a>
</t>
</div>
</t>
</template>
</odoo>