feat(community_membership): tier model, res.partner extension, settings (Session 1-A)

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>
This commit is contained in:
metatroncubeswdev 2026-08-17 20:03:10 -04:00
parent a848373429
commit ae324c30f9
15 changed files with 470 additions and 1 deletions

View File

@ -0,0 +1 @@
from . import models

View File

@ -29,7 +29,15 @@ configuration record managed by the deployment layer.
'portal', 'portal',
'website', 'website',
], ],
'data': [], 'data': [
'security/membership_security.xml',
'security/ir.model.access.csv',
'data/ir_sequence.xml',
'views/membership_tier_views.xml',
'views/res_partner_views.xml',
'views/res_config_settings_views.xml',
'views/membership_menus.xml',
],
'demo': [], 'demo': [],
'images': ['static/description/banner.png'], 'images': ['static/description/banner.png'],
'application': True, 'application': True,

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="seq_membership_member_id" model="ir.sequence">
<field name="name">Membership Member ID</field>
<field name="code">community.membership.member_id</field>
<field name="padding">4</field>
<field name="number_increment">1</field>
<field name="implementation">no_gap</field>
</record>
</odoo>

View File

@ -0,0 +1,3 @@
from . import membership_tier
from . import res_partner
from . import res_config_settings

View File

@ -0,0 +1,80 @@
from odoo import api, fields, models
PERIOD_DURATION_DAYS = {
'annual': 365,
'monthly': 30,
'one_time': None, # never expires
}
class CommunityMembershipTier(models.Model):
_name = 'community.membership.tier'
_description = 'Membership Tier'
_order = 'sequence, id'
name = fields.Char(required=True, translate=True)
code = fields.Char(required=True, help="Short internal code, e.g. 'IND', 'FAM'.")
price = fields.Monetary(currency_field='currency_id', required=True)
currency_id = fields.Many2one(
'res.currency', required=True,
default=lambda self: self.env.company.currency_id,
)
period = fields.Selection(
[
('annual', 'Annual'),
('one_time', 'One-time (lifetime)'),
('monthly', 'Monthly'),
],
default='annual', required=True,
)
member_state_logic = fields.Text(
string='State Notes',
help="Free-text notes for admins on any special membership-state handling "
"this tier needs (e.g. lifetime tiers never move to 'expired').",
)
sequence = fields.Integer(default=10)
product_id = fields.Many2one(
'product.product', string='Membership Product', readonly=True, copy=False,
help="Auto-created service product used to invoice this tier's dues.",
)
active = fields.Boolean(default=True)
_code_uniq = models.Constraint(
'unique(code)', 'A membership tier with this code already exists.',
)
@api.model_create_multi
def create(self, vals_list):
tiers = super().create(vals_list)
for tier in tiers:
if not tier.product_id:
tier.product_id = tier._create_membership_product()
return tiers
def write(self, vals):
res = super().write(vals)
if 'price' in vals or 'name' in vals:
for tier in self:
if tier.product_id:
tier.product_id.write(tier._membership_product_values())
return res
def _membership_product_values(self):
self.ensure_one()
return {
'name': self.name,
'list_price': self.price,
'type': 'service',
'sale_ok': True,
'purchase_ok': False,
'invoice_policy': 'order',
}
def _create_membership_product(self):
self.ensure_one()
product = self.env['product.product'].create(self._membership_product_values())
return product
def duration_days(self):
self.ensure_one()
return PERIOD_DURATION_DAYS.get(self.period)

View File

@ -0,0 +1,29 @@
from odoo import fields, models
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
membership_org_name = fields.Char(
string='Organization Display Name',
config_parameter='community_membership.org_name',
help="Used in membership emails, the QR card, and the verification page. "
"Never hardcode an organization name in module code - set it here.",
)
membership_member_id_format = fields.Char(
string='Member ID Format',
config_parameter='community_membership.member_id_format',
default='MEM-{year}-{seq}',
help="Placeholders: {year} = activation year, {seq} = sequence number.",
)
membership_renewal_offsets = fields.Char(
string='Renewal Reminder Offsets (days)',
config_parameter='community_membership.renewal_offsets',
default='30,14,7',
help="Comma-separated list of days-before-expiry to send renewal reminders.",
)
membership_portal_enabled = fields.Boolean(
string='Enable Member Portal',
config_parameter='community_membership.portal_enabled',
default=True,
)

View File

@ -0,0 +1,63 @@
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)
)

View File

@ -1 +1,4 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_community_membership_tier_manager,community.membership.tier manager,model_community_membership_tier,group_membership_manager,1,1,1,1
access_community_membership_tier_user,community.membership.tier internal read,model_community_membership_tier,base.group_user,1,0,0,0
access_community_membership_tier_portal,community.membership.tier portal read,model_community_membership_tier,base.group_portal,1,0,0,0

1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_community_membership_tier_manager community.membership.tier manager model_community_membership_tier group_membership_manager 1 1 1 1
3 access_community_membership_tier_user community.membership.tier internal read model_community_membership_tier base.group_user 1 0 0 0
4 access_community_membership_tier_portal community.membership.tier portal read model_community_membership_tier base.group_portal 1 0 0 0

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="module_category_membership" model="ir.module.category">
<field name="name">Membership</field>
<field name="sequence">20</field>
</record>
<record id="privilege_membership" model="res.groups.privilege">
<field name="name">Membership</field>
<field name="category_id" ref="module_category_membership"/>
</record>
<record id="group_membership_manager" model="res.groups">
<field name="name">Membership Manager</field>
<field name="privilege_id" ref="privilege_membership"/>
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
<field name="comment">Can manage membership tiers, member records, and renewal settings.</field>
</record>
</odoo>

View File

@ -0,0 +1 @@
from . import test_membership

View File

@ -0,0 +1,69 @@
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestMembership(TransactionCase):
def test_tier_creates_product(self):
tier = self.env['community.membership.tier'].create({
'name': 'Individual',
'code': 'IND',
'price': 50.0,
'period': 'annual',
})
self.assertTrue(tier.product_id, "Creating a tier should auto-create a membership product")
self.assertEqual(tier.product_id.list_price, 50.0)
def test_member_id_generated_on_activation(self):
tier = self.env['community.membership.tier'].create({
'name': 'Individual',
'code': 'IND2',
'price': 50.0,
'period': 'annual',
})
partner = self.env['res.partner'].create({
'name': 'Jane Member',
'membership_tier_id': tier.id,
})
self.assertFalse(partner.membership_member_id)
self.assertEqual(partner.membership_state, 'none')
partner.action_activate_membership()
self.assertEqual(partner.membership_state, 'active')
self.assertTrue(partner.membership_member_id, "Member ID should be generated on activation")
self.assertTrue(partner.membership_member_id.startswith('MEM-'))
self.assertTrue(partner.membership_start)
self.assertTrue(partner.membership_expiry, "Annual tier should compute an expiry date")
def test_one_time_tier_never_expires(self):
tier = self.env['community.membership.tier'].create({
'name': 'Lifetime',
'code': 'LIFE',
'price': 500.0,
'period': 'one_time',
})
partner = self.env['res.partner'].create({
'name': 'Life Member',
'membership_tier_id': tier.id,
})
partner.action_activate_membership()
self.assertEqual(partner.membership_state, 'active')
self.assertFalse(partner.membership_expiry, "One-time/lifetime tier should not set an expiry")
def test_member_id_format_is_configurable(self):
self.env['ir.config_parameter'].sudo().set_param(
'community_membership.member_id_format', 'TESTORG-{year}-{seq}'
)
tier = self.env['community.membership.tier'].create({
'name': 'Individual',
'code': 'IND3',
'price': 50.0,
'period': 'annual',
})
partner = self.env['res.partner'].create({
'name': 'Custom Format Member',
'membership_tier_id': tier.id,
})
partner.action_activate_membership()
self.assertTrue(partner.membership_member_id.startswith('TESTORG-'))

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<menuitem id="menu_membership_root" name="Membership" sequence="25"
groups="community_membership.group_membership_manager"/>
<menuitem id="menu_membership_members" name="Members"
parent="menu_membership_root" action="action_membership_members" sequence="10"/>
<menuitem id="menu_membership_configuration" name="Configuration"
parent="menu_membership_root" sequence="90"/>
<menuitem id="menu_membership_tiers" name="Membership Tiers"
parent="menu_membership_configuration" action="action_membership_tier" sequence="10"/>
<menuitem id="menu_membership_settings" name="Settings"
parent="menu_membership_configuration" action="action_membership_config_settings" sequence="90"/>
</odoo>

View File

@ -0,0 +1,55 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_membership_tier_list" model="ir.ui.view">
<field name="name">community.membership.tier.list</field>
<field name="model">community.membership.tier</field>
<field name="arch" type="xml">
<list string="Membership Tiers">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="code"/>
<field name="price" widget="monetary"/>
<field name="currency_id" column_invisible="1"/>
<field name="period"/>
<field name="active" column_invisible="1"/>
</list>
</field>
</record>
<record id="view_membership_tier_form" model="ir.ui.view">
<field name="name">community.membership.tier.form</field>
<field name="model">community.membership.tier</field>
<field name="arch" type="xml">
<form string="Membership Tier">
<sheet>
<div class="oe_title">
<label for="name"/>
<h1><field name="name" placeholder="e.g. Individual"/></h1>
</div>
<group>
<group>
<field name="code"/>
<field name="price" widget="monetary"/>
<field name="currency_id" groups="base.group_multi_currency"/>
<field name="period"/>
</group>
<group>
<field name="sequence"/>
<field name="active"/>
<field name="product_id" readonly="1"/>
</group>
</group>
<group string="Notes">
<field name="member_state_logic" nolabel="1" placeholder="Optional admin notes on state handling for this tier..."/>
</group>
</sheet>
</form>
</field>
</record>
<record id="action_membership_tier" model="ir.actions.act_window">
<field name="name">Membership Tiers</field>
<field name="res_model">community.membership.tier</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

View File

@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="res_config_settings_view_form_membership" model="ir.ui.view">
<field name="name">res.config.settings.view.form.membership</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//form" position="inside">
<app data-string="Membership" string="Membership" name="community_membership"
groups="community_membership.group_membership_manager">
<block title="Membership" id="membership_settings">
<setting id="membership_org_name_setting" string="Organization Name"
help="Used in membership emails, the QR card, and the verification page">
<field name="membership_org_name" placeholder="e.g. My Community Organization"/>
</setting>
<setting id="membership_member_id_format_setting" string="Member ID Format"
help="Placeholders: {year}, {seq}">
<field name="membership_member_id_format"/>
</setting>
<setting id="membership_renewal_offsets_setting" string="Renewal Reminder Offsets"
help="Comma-separated days-before-expiry to send renewal reminders">
<field name="membership_renewal_offsets"/>
</setting>
<setting id="membership_portal_enabled_setting" string="Member Portal">
<field name="membership_portal_enabled"/>
</setting>
</block>
</app>
</xpath>
</field>
</record>
<record id="action_membership_config_settings" model="ir.actions.act_window">
<field name="name">Membership Settings</field>
<field name="res_model">res.config.settings</field>
<field name="view_mode">form</field>
<field name="target">current</field>
<field name="context">{'module': 'community_membership'}</field>
</record>
</odoo>

View File

@ -0,0 +1,71 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_partner_form_membership" model="ir.ui.view">
<field name="name">res.partner.form.membership</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="base.view_partner_form"/>
<field name="arch" type="xml">
<notebook position="inside">
<page string="Membership" name="membership">
<group>
<group>
<field name="membership_member_id"/>
<field name="membership_tier_id"/>
<field name="membership_state"/>
<field name="family_head_id"/>
</group>
<group>
<field name="membership_start"/>
<field name="membership_expiry"/>
<field name="is_volunteer"/>
<field name="volunteer_hours" invisible="not is_volunteer"/>
</group>
</group>
<button name="action_activate_membership" type="object"
string="Activate Membership" class="btn-primary"
invisible="membership_state == 'active'"/>
</page>
</notebook>
</field>
</record>
<record id="view_partner_list_membership" model="ir.ui.view">
<field name="name">res.partner.list.membership</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="base.view_partner_tree"/>
<field name="arch" type="xml">
<field name="display_name" position="after">
<field name="membership_member_id" optional="show"/>
<field name="membership_tier_id" optional="show"/>
<field name="membership_state" optional="show"/>
</field>
</field>
</record>
<record id="view_partner_kanban_membership" model="ir.ui.view">
<field name="name">res.partner.kanban.membership.search</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="base.view_res_partner_filter"/>
<field name="arch" type="xml">
<filter name="type_person" position="after">
<separator/>
<filter string="Members" name="is_member" domain="[('membership_state', '!=', 'none')]"/>
<filter string="Active Members" name="membership_active" domain="[('membership_state', '=', 'active')]"/>
<filter string="Renewal Due" name="membership_renewal_due" domain="[('membership_state', '=', 'renewal_due')]"/>
<filter string="Expired Members" name="membership_expired" domain="[('membership_state', '=', 'expired')]"/>
<group name="group_by" position="inside">
<filter string="Membership Tier" name="group_by_tier" context="{'group_by': 'membership_tier_id'}"/>
<filter string="Membership State" name="group_by_state" context="{'group_by': 'membership_state'}"/>
</group>
</filter>
</field>
</record>
<record id="action_membership_members" model="ir.actions.act_window">
<field name="name">Members</field>
<field name="res_model">res.partner</field>
<field name="view_mode">list,kanban,form</field>
<field name="domain">[('membership_state', '!=', 'none')]</field>
<field name="context">{'default_membership_state': 'invoiced'}</field>
</record>
</odoo>