From ae324c30f99a0fd2140ba81e28c2b196a5258895 Mon Sep 17 00:00:00 2001 From: metatroncubeswdev Date: Mon, 17 Aug 2026 20:03:10 -0400 Subject: [PATCH] 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 // 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 --- addons/community_membership/__init__.py | 1 + addons/community_membership/__manifest__.py | 10 ++- .../community_membership/data/ir_sequence.xml | 10 +++ .../community_membership/models/__init__.py | 3 + .../models/membership_tier.py | 80 +++++++++++++++++++ .../models/res_config_settings.py | 29 +++++++ .../models/res_partner.py | 63 +++++++++++++++ .../security/ir.model.access.csv | 3 + .../security/membership_security.xml | 19 +++++ addons/community_membership/tests/__init__.py | 1 + .../tests/test_membership.py | 69 ++++++++++++++++ .../views/membership_menus.xml | 17 ++++ .../views/membership_tier_views.xml | 55 +++++++++++++ .../views/res_config_settings_views.xml | 40 ++++++++++ .../views/res_partner_views.xml | 71 ++++++++++++++++ 15 files changed, 470 insertions(+), 1 deletion(-) create mode 100644 addons/community_membership/data/ir_sequence.xml create mode 100644 addons/community_membership/models/__init__.py create mode 100644 addons/community_membership/models/membership_tier.py create mode 100644 addons/community_membership/models/res_config_settings.py create mode 100644 addons/community_membership/models/res_partner.py create mode 100644 addons/community_membership/security/membership_security.xml create mode 100644 addons/community_membership/tests/test_membership.py create mode 100644 addons/community_membership/views/membership_menus.xml create mode 100644 addons/community_membership/views/membership_tier_views.xml create mode 100644 addons/community_membership/views/res_config_settings_views.xml create mode 100644 addons/community_membership/views/res_partner_views.xml diff --git a/addons/community_membership/__init__.py b/addons/community_membership/__init__.py index e69de29..0650744 100644 --- a/addons/community_membership/__init__.py +++ b/addons/community_membership/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/addons/community_membership/__manifest__.py b/addons/community_membership/__manifest__.py index 15a84c5..d3d1c80 100644 --- a/addons/community_membership/__manifest__.py +++ b/addons/community_membership/__manifest__.py @@ -29,7 +29,15 @@ configuration record managed by the deployment layer. 'portal', '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': [], 'images': ['static/description/banner.png'], 'application': True, diff --git a/addons/community_membership/data/ir_sequence.xml b/addons/community_membership/data/ir_sequence.xml new file mode 100644 index 0000000..b4a2374 --- /dev/null +++ b/addons/community_membership/data/ir_sequence.xml @@ -0,0 +1,10 @@ + + + + Membership Member ID + community.membership.member_id + 4 + 1 + no_gap + + diff --git a/addons/community_membership/models/__init__.py b/addons/community_membership/models/__init__.py new file mode 100644 index 0000000..7da90c5 --- /dev/null +++ b/addons/community_membership/models/__init__.py @@ -0,0 +1,3 @@ +from . import membership_tier +from . import res_partner +from . import res_config_settings diff --git a/addons/community_membership/models/membership_tier.py b/addons/community_membership/models/membership_tier.py new file mode 100644 index 0000000..550396a --- /dev/null +++ b/addons/community_membership/models/membership_tier.py @@ -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) diff --git a/addons/community_membership/models/res_config_settings.py b/addons/community_membership/models/res_config_settings.py new file mode 100644 index 0000000..06936ef --- /dev/null +++ b/addons/community_membership/models/res_config_settings.py @@ -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, + ) diff --git a/addons/community_membership/models/res_partner.py b/addons/community_membership/models/res_partner.py new file mode 100644 index 0000000..3e28c3b --- /dev/null +++ b/addons/community_membership/models/res_partner.py @@ -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) + ) diff --git a/addons/community_membership/security/ir.model.access.csv b/addons/community_membership/security/ir.model.access.csv index 97dd8b9..4be1150 100644 --- a/addons/community_membership/security/ir.model.access.csv +++ b/addons/community_membership/security/ir.model.access.csv @@ -1 +1,4 @@ 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 diff --git a/addons/community_membership/security/membership_security.xml b/addons/community_membership/security/membership_security.xml new file mode 100644 index 0000000..5695491 --- /dev/null +++ b/addons/community_membership/security/membership_security.xml @@ -0,0 +1,19 @@ + + + + Membership + 20 + + + + Membership + + + + + Membership Manager + + + Can manage membership tiers, member records, and renewal settings. + + diff --git a/addons/community_membership/tests/__init__.py b/addons/community_membership/tests/__init__.py index e69de29..4dad2bf 100644 --- a/addons/community_membership/tests/__init__.py +++ b/addons/community_membership/tests/__init__.py @@ -0,0 +1 @@ +from . import test_membership diff --git a/addons/community_membership/tests/test_membership.py b/addons/community_membership/tests/test_membership.py new file mode 100644 index 0000000..dfbe7d2 --- /dev/null +++ b/addons/community_membership/tests/test_membership.py @@ -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-')) diff --git a/addons/community_membership/views/membership_menus.xml b/addons/community_membership/views/membership_menus.xml new file mode 100644 index 0000000..1febce8 --- /dev/null +++ b/addons/community_membership/views/membership_menus.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + diff --git a/addons/community_membership/views/membership_tier_views.xml b/addons/community_membership/views/membership_tier_views.xml new file mode 100644 index 0000000..011fb21 --- /dev/null +++ b/addons/community_membership/views/membership_tier_views.xml @@ -0,0 +1,55 @@ + + + + community.membership.tier.list + community.membership.tier + + + + + + + + + + + + + + + community.membership.tier.form + community.membership.tier + +
+ +
+
+ + + + + + + + + + + + + + + + +
+
+
+
+ + + Membership Tiers + community.membership.tier + list,form + +
diff --git a/addons/community_membership/views/res_config_settings_views.xml b/addons/community_membership/views/res_config_settings_views.xml new file mode 100644 index 0000000..50535cc --- /dev/null +++ b/addons/community_membership/views/res_config_settings_views.xml @@ -0,0 +1,40 @@ + + + + res.config.settings.view.form.membership + res.config.settings + + + + + + + + + + + + + + + + + + + + + + + + + Membership Settings + res.config.settings + form + current + {'module': 'community_membership'} + + diff --git a/addons/community_membership/views/res_partner_views.xml b/addons/community_membership/views/res_partner_views.xml new file mode 100644 index 0000000..1f568fa --- /dev/null +++ b/addons/community_membership/views/res_partner_views.xml @@ -0,0 +1,71 @@ + + + + res.partner.form.membership + res.partner + + + + + + + + + + + + + + + + + + +