import base64 import io import qrcode from odoo import _, api, fields, models from odoo.exceptions import ValidationError MEMBER_ID_SEQUENCE_CODE = 'community.membership.member_id' DEFAULT_MEMBER_ID_FORMAT = 'MEM-{year}-{seq}' DEFAULT_RENEWAL_OFFSETS = '30,14,7' MANAGER_GROUP = 'community_membership.group_membership_manager' BIRTH_MONTHS = [ ('1', 'January'), ('2', 'February'), ('3', 'March'), ('4', 'April'), ('5', 'May'), ('6', 'June'), ('7', 'July'), ('8', 'August'), ('9', 'September'), ('10', 'October'), ('11', 'November'), ('12', 'December'), ] 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.", ) family_member_ids = fields.One2many( 'res.partner', 'family_head_id', string='Family Members', help="Spouse, children or other people covered by this member's family membership.", ) first_name = fields.Char(string='First Name') last_name = fields.Char(string='Last Name') # Only month and year of birth are collected, never the full date. birth_month = fields.Selection(BIRTH_MONTHS, string='Birth Month', groups=MANAGER_GROUP) birth_year = fields.Integer(string='Birth Year', groups=MANAGER_GROUP) member_age = fields.Integer( string='Age', compute='_compute_member_age', groups=MANAGER_GROUP, help="Age in whole years, worked out from the birth month and year.", ) membership_approval_state = fields.Selection( [ ('pending', 'Pending Approval'), ('approved', 'Approved'), ('rejected', 'Rejected'), ], string='Board Approval', copy=False, tracking=True, help="Every new membership is subject to approval by the Board of Directors.", ) membership_terms_accepted = fields.Boolean(string='Accepted Terms & Conditions', copy=False) membership_proof_document = fields.Binary( string='Proof of Eligibility', attachment=True, copy=False, groups=MANAGER_GROUP, help="Document confirming eligibility for the tier, e.g. proof of age or full-time student status.", ) membership_proof_filename = fields.Char(string='Proof File Name', copy=False, groups=MANAGER_GROUP) 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') @api.depends('birth_month', 'birth_year') def _compute_member_age(self): today = fields.Date.context_today(self) for partner in self: partner.member_age = partner._age_on(today) def _age_on(self, day): """Whole years of age on `day`, or 0 when the birth month or year is unknown.""" self.ensure_one() if not self.birth_month or not self.birth_year: return 0 age = day.year - self.birth_year if day.month < int(self.birth_month): age -= 1 return max(age, 0) @api.model_create_multi def create(self, vals_list): for vals in vals_list: if not vals.get('name'): full_name = ' '.join(part for part in (vals.get('first_name'), vals.get('last_name')) if part) if full_name: vals['name'] = full_name return super().create(vals_list) @api.onchange('first_name', 'last_name') def _onchange_first_last_name(self): full_name = ' '.join(part for part in (self.first_name, self.last_name) if part) if full_name: self.name = full_name @api.constrains('birth_year') def _check_birth_year(self): this_year = fields.Date.context_today(self).year for partner in self.sudo(): if partner.birth_year and not 1900 <= partner.birth_year <= this_year: raise ValidationError(_("Birth year must be between 1900 and %s.", this_year)) @api.constrains('membership_tier_id', 'birth_month', 'birth_year') def _check_tier_minimum_age(self): today = fields.Date.context_today(self) for partner in self.sudo(): tier = partner.membership_tier_id if not tier.min_age or not partner.birth_year: continue if partner._age_on(today) < tier.min_age: raise ValidationError(_( "%(name)s is too young for the %(tier)s tier, which requires age %(age)s or older.", name=partner.display_name, tier=tier.name, age=tier.min_age, )) def action_approve_membership(self): self.write({'membership_approval_state': 'approved'}) template = self.env.ref('community_membership.mail_template_membership_approved', raise_if_not_found=False) for partner in self: if partner.membership_state in ('none', 'invoiced') and partner.membership_tier_id: partner.action_activate_membership() if template and partner.email: template.send_mail(partner.id, force_send=True) if hasattr(partner, 'message_post'): partner.message_post(body=_("Membership application approved by the Board of Directors.")) return True def action_reject_membership(self): self.write({'membership_approval_state': 'rejected'}) template = self.env.ref('community_membership.mail_template_membership_rejected', raise_if_not_found=False) for partner in self: draft_invoices = self.env['account.move'].sudo().search([ ('partner_id', '=', partner.id), ('move_type', '=', 'out_invoice'), ('state', '=', 'draft'), ]) if draft_invoices: draft_invoices.button_cancel() if template and partner.email: template.send_mail(partner.id, force_send=True) if hasattr(partner, 'message_post'): partner.message_post(body=_("Membership application rejected by the Board of Directors.")) return True 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 expiry = partner.membership_tier_id.get_expiry_date(today) if expiry: vals['membership_expiry'] = expiry 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, })], })