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:
parent
e5d90a83d4
commit
190e51a7da
@ -1 +1,2 @@
|
||||
from . import models
|
||||
from . import controllers
|
||||
|
||||
@ -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'],
|
||||
},
|
||||
}
|
||||
|
||||
2
addons/community_membership/controllers/__init__.py
Normal file
2
addons/community_membership/controllers/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
from . import main
|
||||
from . import portal
|
||||
26
addons/community_membership/controllers/main.py
Normal file
26
addons/community_membership/controllers/main.py
Normal 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)
|
||||
47
addons/community_membership/controllers/portal.py
Normal file
47
addons/community_membership/controllers/portal.py
Normal 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'),
|
||||
])
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
@ -1,2 +1,3 @@
|
||||
from . import test_membership
|
||||
from . import test_membership_renewal
|
||||
from . import test_membership_verify
|
||||
|
||||
39
addons/community_membership/tests/test_membership_verify.py
Normal file
39
addons/community_membership/tests/test_membership_verify.py
Normal 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")
|
||||
@ -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>
|
||||
51
addons/community_membership/views/portal_templates.xml
Normal file
51
addons/community_membership/views/portal_templates.xml
Normal 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>
|
||||
Loading…
x
Reference in New Issue
Block a user