community_membership: - Tiers get a fixed-term period (term_years), minimum age, a proof-document flag, a family-member flag and a signup description. - Members get first/last name (composing the contact name), month and year of birth with computed age, board approval state with approve/reject buttons, terms acceptance, a proof-of-eligibility upload and an inline family members list. Birth date and proof document are restricted to Membership Managers. - Constraints: plausible birth year; a tier's minimum age is enforced when the birth year is known. - Fix pre-existing tests that collided with deployment-seeded tier codes and member ID format. tncsc_deployment: - Five Yes/No eligibility questions from the tncsc.com registration form (manager-only), shown in an Eligibility section of the member form. - Tiers now match tncsc.com/membership-registration: Annual/Family $30, Long-Term (10 yr) $250, Life $500, Senior Annual (65+) $15, Senior Life (60+) $250, Student Annual $10 (CAD). - 19.0.1.1.0 post-migrate rewrites existing tiers (noupdate data) and archives the unused Individual tier. scripts/migrate_wp_members.py maps WordPress levels to the new LONG and SENLIFE tiers. .mcp.json (holds credentials) is now gitignored. Verified on the local Docker stack: -u on tncsc_site ran the migration cleanly, and a fresh install with --test-enable passed 32/32 tests for community_membership and tncsc_deployment. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
252 lines
10 KiB
Python
252 lines
10 KiB
Python
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'})
|
|
return True
|
|
|
|
def action_reject_membership(self):
|
|
self.write({'membership_approval_state': 'rejected'})
|
|
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,
|
|
})],
|
|
})
|