import base64 import io import qrcode from dateutil.relativedelta import relativedelta from odoo import api, fields, models MEMBER_ID_SEQUENCE_CODE = 'community.membership.member_id' DEFAULT_MEMBER_ID_FORMAT = 'MEM-{year}-{seq}' DEFAULT_RENEWAL_OFFSETS = '30,14,7' class ResPartner(models.Model): _inherit = 'res.partner' membership_member_id = fields.Char( string='Member ID', copy=False, index=True, readonly=True, ) membership_tier_id = fields.Many2one('community.membership.tier', string='Membership Tier') membership_state = fields.Selection( [ ('none', 'Not a Member'), ('invoiced', 'Invoiced'), ('active', 'Active'), ('renewal_due', 'Renewal Due'), ('expired', 'Expired'), ], default='none', copy=False, tracking=True, ) membership_start = fields.Date(copy=False) membership_expiry = fields.Date(copy=False) family_head_id = fields.Many2one( 'res.partner', string='Family Head', domain=[('membership_tier_id', '!=', False)], help="If this member belongs to a family membership, the primary member it is grouped under.", ) 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) for partner in self: if not partner.membership_tier_id: continue vals = {'membership_state': 'active'} if not partner.membership_member_id: vals['membership_member_id'] = partner._generate_member_id() if not partner.membership_start: vals['membership_start'] = today duration_days = partner.membership_tier_id.duration_days() if duration_days: vals['membership_expiry'] = today + relativedelta(days=duration_days) partner.write(vals) return True def _generate_member_id(self): self.ensure_one() get_param = self.env['ir.config_parameter'].sudo().get_param member_id_format = get_param('community_membership.member_id_format', DEFAULT_MEMBER_ID_FORMAT) seq_number = self.env['ir.sequence'].next_by_code(MEMBER_ID_SEQUENCE_CODE) or '1' year = fields.Date.context_today(self).year return ( member_id_format .replace('{year}', str(year)) .replace('{seq}', seq_number) ) @api.model def _get_renewal_offsets(self): """Configured days-before-expiry reminder offsets, sorted descending.""" raw = self.env['ir.config_parameter'].sudo().get_param( 'community_membership.renewal_offsets', DEFAULT_RENEWAL_OFFSETS ) offsets = [] for chunk in (raw or '').split(','): chunk = chunk.strip() if chunk.isdigit(): offsets.append(int(chunk)) return sorted(set(offsets), reverse=True) or [int(d) for d in DEFAULT_RENEWAL_OFFSETS.split(',')] @api.model def _cron_process_membership_renewals(self): """Daily job: send renewal reminders, flip state at the final offset, expire past due.""" today = fields.Date.context_today(self) offsets = self._get_renewal_offsets() final_offset = min(offsets) candidates = self.search([ ('membership_expiry', '!=', False), ('membership_state', 'in', ['active', 'renewal_due']), ]) for partner in candidates: days_left = (partner.membership_expiry - today).days if days_left < 0: partner.write({'membership_state': 'expired'}) partner._send_membership_mail('community_membership.mail_template_membership_expired') partner._create_renewal_invoice() continue if partner.membership_state == 'active' and days_left == final_offset: partner.write({'membership_state': 'renewal_due'}) partner._send_membership_mail('community_membership.mail_template_membership_renewal_due') elif partner.membership_state == 'active' and days_left in offsets: partner._send_membership_mail('community_membership.mail_template_membership_renewal_upcoming') return True def _send_membership_mail(self, template_xmlid): self.ensure_one() template = self.env.ref(template_xmlid, raise_if_not_found=False) if template: template.send_mail(self.id, force_send=False) def _create_renewal_invoice(self): """Create a draft renewal invoice for the member's tier product, for admin review.""" self.ensure_one() tier = self.membership_tier_id if not tier or not tier.product_id: return self.env['account.move'] return self.env['account.move'].create({ 'move_type': 'out_invoice', 'partner_id': self.id, 'invoice_origin': 'Membership Renewal', 'invoice_line_ids': [(0, 0, { 'product_id': tier.product_id.id, 'quantity': 1, 'price_unit': tier.price, })], })