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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ res.partner.list.membership
+ res.partner
+
+
+
+
+
+
+
+
+
+
+
+ res.partner.kanban.membership.search
+ res.partner
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Members
+ res.partner
+ list,kanban,form
+ [('membership_state', '!=', 'none')]
+ {'default_membership_state': 'invoiced'}
+
+