diff --git a/addons/community_membership/__init__.py b/addons/community_membership/__init__.py index 0650744..f7209b1 100644 --- a/addons/community_membership/__init__.py +++ b/addons/community_membership/__init__.py @@ -1 +1,2 @@ from . import models +from . import controllers diff --git a/addons/community_membership/__manifest__.py b/addons/community_membership/__manifest__.py index 13f3d85..8a47cb2 100644 --- a/addons/community_membership/__manifest__.py +++ b/addons/community_membership/__manifest__.py @@ -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'], + }, } diff --git a/addons/community_membership/controllers/__init__.py b/addons/community_membership/controllers/__init__.py new file mode 100644 index 0000000..984e838 --- /dev/null +++ b/addons/community_membership/controllers/__init__.py @@ -0,0 +1,2 @@ +from . import main +from . import portal diff --git a/addons/community_membership/controllers/main.py b/addons/community_membership/controllers/main.py new file mode 100644 index 0000000..e66df0e --- /dev/null +++ b/addons/community_membership/controllers/main.py @@ -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/'], 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) diff --git a/addons/community_membership/controllers/portal.py b/addons/community_membership/controllers/portal.py new file mode 100644 index 0000000..26895c0 --- /dev/null +++ b/addons/community_membership/controllers/portal.py @@ -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'), + ]) diff --git a/addons/community_membership/models/res_partner.py b/addons/community_membership/models/res_partner.py index a0afcd1..5ab581f 100644 --- a/addons/community_membership/models/res_partner.py +++ b/addons/community_membership/models/res_partner.py @@ -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) diff --git a/addons/community_membership/report/membership_card_report.xml b/addons/community_membership/report/membership_card_report.xml new file mode 100644 index 0000000..28dd81f --- /dev/null +++ b/addons/community_membership/report/membership_card_report.xml @@ -0,0 +1,67 @@ + + + + Membership Card (85x54mm) + custom + 85 + 54 + Landscape + 2 + 2 + 2 + 2 + + 96 + + + + Membership Card + res.partner + qweb-pdf + community_membership.report_membership_card + community_membership.report_membership_card + + 'Membership Card - %s' % (object.membership_member_id or object.name) + + report + + + + diff --git a/addons/community_membership/tests/__init__.py b/addons/community_membership/tests/__init__.py index f68c4a3..65832d0 100644 --- a/addons/community_membership/tests/__init__.py +++ b/addons/community_membership/tests/__init__.py @@ -1,2 +1,3 @@ from . import test_membership from . import test_membership_renewal +from . import test_membership_verify diff --git a/addons/community_membership/tests/test_membership_verify.py b/addons/community_membership/tests/test_membership_verify.py new file mode 100644 index 0000000..9f38b57 --- /dev/null +++ b/addons/community_membership/tests/test_membership_verify.py @@ -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") diff --git a/addons/community_membership/views/membership_verify_templates.xml b/addons/community_membership/views/membership_verify_templates.xml new file mode 100644 index 0000000..d703b53 --- /dev/null +++ b/addons/community_membership/views/membership_verify_templates.xml @@ -0,0 +1,28 @@ + + + + diff --git a/addons/community_membership/views/portal_templates.xml b/addons/community_membership/views/portal_templates.xml new file mode 100644 index 0000000..15b3bc7 --- /dev/null +++ b/addons/community_membership/views/portal_templates.xml @@ -0,0 +1,51 @@ + + + + + +