Adds the membership foundation: community.membership.tier (admin-configurable
tiers, each auto-creating a linked invoicing product), res.partner membership
fields (member ID, tier, state, family grouping, volunteer tracking), and a
res.config.settings page (org name, member-ID format, renewal offsets, portal
toggle) - all backed by ir.config_parameter so nothing is hardcoded.
Member IDs are generated on activation via a configurable format string
({year}/{seq}) resolved against an ir.sequence. Adds the Membership Manager
group/menu and views (tier list/form, partner form tab, list columns, search
filters, settings page).
Along the way, hit two real Odoo 19 API changes vs. older tutorials/docs:
res.groups.category_id was replaced by privilege_id -> res.groups.privilege,
and module settings pages now use the <app>/<block>/<setting> pattern
inherited from base.res_config_settings_view_form rather than raw divs.
Verified by upgrading the module against a live Odoo 19 + Postgres 16
container: installs clean, all 4 tests pass (tier->product creation, member
ID generation on activation, one-time/lifetime tiers never expire,
configurable ID format), and the module still installs cleanly for its
dependents (community_benefits, tncsc_deployment).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
64 lines
2.5 KiB
Python
64 lines
2.5 KiB
Python
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}'
|
|
|
|
|
|
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')
|
|
|
|
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)
|
|
)
|