From 657792b2688f969285e88b886543a29e94804d9b Mon Sep 17 00:00:00 2001 From: metatroncubeswdev Date: Sun, 27 Sep 2026 10:46:31 -0400 Subject: [PATCH] Add member application fields and the six real TNCSC membership plans 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 --- .gitignore | 3 + addons/community_membership/CHANGELOG.rst | 9 ++ addons/community_membership/__manifest__.py | 2 +- .../models/membership_tier.py | 35 ++++++ .../models/res_partner.py | 104 +++++++++++++++++- addons/community_membership/tests/__init__.py | 1 + .../tests/test_membership.py | 8 +- .../tests/test_membership_member_details.py | 58 ++++++++++ .../views/membership_tier_views.xml | 15 +++ .../views/res_partner_views.xml | 57 +++++++++- addons/tncsc_deployment/__manifest__.py | 3 +- .../tncsc_deployment/data/membership_data.xml | 80 +++++++++----- .../migrations/19.0.1.1.0/post-migrate.py | 42 +++++++ addons/tncsc_deployment/models/__init__.py | 1 + addons/tncsc_deployment/models/res_partner.py | 36 ++++++ .../tests/test_tncsc_deployment.py | 30 ++++- .../views/res_partner_views.xml | 21 ++++ scripts/migrate_wp_members.py | 9 +- 18 files changed, 462 insertions(+), 52 deletions(-) create mode 100644 addons/community_membership/tests/test_membership_member_details.py create mode 100644 addons/tncsc_deployment/migrations/19.0.1.1.0/post-migrate.py create mode 100644 addons/tncsc_deployment/models/res_partner.py create mode 100644 addons/tncsc_deployment/views/res_partner_views.xml diff --git a/.gitignore b/.gitignore index 918e572..a6d9a58 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,6 @@ data/raw/* *.csv.bak .env + +# Local MCP config (contains credentials) +.mcp.json diff --git a/addons/community_membership/CHANGELOG.rst b/addons/community_membership/CHANGELOG.rst index dc462ac..7a72843 100644 --- a/addons/community_membership/CHANGELOG.rst +++ b/addons/community_membership/CHANGELOG.rst @@ -1,6 +1,15 @@ Changelog ========= +19.0.1.1.0 (2026-09-27) +~~~~~~~~~~~~~~~~~~~~~~~~ +* Tiers: fixed-term period (``term_years``), minimum age, proof-document + flag, family-member flag and a signup description. +* Members: first/last name, month and year of birth with computed age, + board approval state (approve/reject), terms acceptance, proof-of- + eligibility upload and an inline family members list. Birth date and + proof document are visible to Membership Managers only. + 19.0.1.0.0 (2026-08-17) ~~~~~~~~~~~~~~~~~~~~~~~~ * Initial release: configurable membership tiers, ``res.partner`` extension diff --git a/addons/community_membership/__manifest__.py b/addons/community_membership/__manifest__.py index 8a47cb2..c5c38a5 100644 --- a/addons/community_membership/__manifest__.py +++ b/addons/community_membership/__manifest__.py @@ -1,6 +1,6 @@ { 'name': 'Community OS - Membership', - 'version': '19.0.1.0.0', + 'version': '19.0.1.1.0', 'category': 'Sales/Members', 'summary': 'Configurable membership tiers, family grouping, renewals, QR membership card and self-service portal', 'description': """ diff --git a/addons/community_membership/models/membership_tier.py b/addons/community_membership/models/membership_tier.py index 550396a..8ed1e50 100644 --- a/addons/community_membership/models/membership_tier.py +++ b/addons/community_membership/models/membership_tier.py @@ -1,3 +1,5 @@ +from dateutil.relativedelta import relativedelta + from odoo import api, fields, models PERIOD_DURATION_DAYS = { @@ -24,9 +26,30 @@ class CommunityMembershipTier(models.Model): ('annual', 'Annual'), ('one_time', 'One-time (lifetime)'), ('monthly', 'Monthly'), + ('fixed_term', 'Fixed term (years)'), ], default='annual', required=True, ) + term_years = fields.Integer( + string='Term (Years)', default=1, + help="Length of the membership in years, for fixed-term tiers (e.g. 10 for a long-term membership).", + ) + description = fields.Text( + translate=True, + help="Who the tier is for and what it covers, shown to applicants on the signup page.", + ) + min_age = fields.Integer( + string='Minimum Age', + help="Applicants must be at least this old to choose this tier. 0 means no age limit.", + ) + requires_proof = fields.Boolean( + string='Requires Proof Document', + help="Applicants may be asked to upload proof of eligibility (e.g. age or student status).", + ) + allows_family_members = fields.Boolean( + string='Includes Family Members', + help="The membership also covers family members, who can be listed on the member's record.", + ) member_state_logic = fields.Text( string='State Notes', help="Free-text notes for admins on any special membership-state handling " @@ -42,6 +65,10 @@ class CommunityMembershipTier(models.Model): _code_uniq = models.Constraint( 'unique(code)', 'A membership tier with this code already exists.', ) + _term_years_positive = models.Constraint( + "CHECK(period != 'fixed_term' OR term_years > 0)", + 'A fixed-term tier needs a term of at least one year.', + ) @api.model_create_multi def create(self, vals_list): @@ -78,3 +105,11 @@ class CommunityMembershipTier(models.Model): def duration_days(self): self.ensure_one() return PERIOD_DURATION_DAYS.get(self.period) + + def get_expiry_date(self, start): + """Expiry for a membership of this tier starting on `start`, or False if it never expires.""" + self.ensure_one() + if self.period == 'fixed_term': + return start + relativedelta(years=self.term_years) + duration_days = self.duration_days() + return start + relativedelta(days=duration_days) if duration_days else False diff --git a/addons/community_membership/models/res_partner.py b/addons/community_membership/models/res_partner.py index 5ab581f..9ba2e66 100644 --- a/addons/community_membership/models/res_partner.py +++ b/addons/community_membership/models/res_partner.py @@ -2,13 +2,20 @@ import base64 import io import qrcode -from dateutil.relativedelta import relativedelta -from odoo import api, fields, models +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): @@ -35,11 +42,98 @@ class ResPartner(models.Model): 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: @@ -74,9 +168,9 @@ class ResPartner(models.Model): 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) + expiry = partner.membership_tier_id.get_expiry_date(today) + if expiry: + vals['membership_expiry'] = expiry partner.write(vals) return True diff --git a/addons/community_membership/tests/__init__.py b/addons/community_membership/tests/__init__.py index 65832d0..1f7bdc6 100644 --- a/addons/community_membership/tests/__init__.py +++ b/addons/community_membership/tests/__init__.py @@ -1,3 +1,4 @@ from . import test_membership from . import test_membership_renewal from . import test_membership_verify +from . import test_membership_member_details diff --git a/addons/community_membership/tests/test_membership.py b/addons/community_membership/tests/test_membership.py index dfbe7d2..555f26f 100644 --- a/addons/community_membership/tests/test_membership.py +++ b/addons/community_membership/tests/test_membership.py @@ -7,7 +7,7 @@ class TestMembership(TransactionCase): def test_tier_creates_product(self): tier = self.env['community.membership.tier'].create({ 'name': 'Individual', - 'code': 'IND', + 'code': 'TEST-IND', 'price': 50.0, 'period': 'annual', }) @@ -15,6 +15,10 @@ class TestMembership(TransactionCase): self.assertEqual(tier.product_id.list_price, 50.0) def test_member_id_generated_on_activation(self): + # A deployment module may seed its own format; pin the product default. + self.env['ir.config_parameter'].sudo().set_param( + 'community_membership.member_id_format', 'MEM-{year}-{seq}' + ) tier = self.env['community.membership.tier'].create({ 'name': 'Individual', 'code': 'IND2', @@ -39,7 +43,7 @@ class TestMembership(TransactionCase): def test_one_time_tier_never_expires(self): tier = self.env['community.membership.tier'].create({ 'name': 'Lifetime', - 'code': 'LIFE', + 'code': 'TEST-LIFE', 'price': 500.0, 'period': 'one_time', }) diff --git a/addons/community_membership/tests/test_membership_member_details.py b/addons/community_membership/tests/test_membership_member_details.py new file mode 100644 index 0000000..0afaf7b --- /dev/null +++ b/addons/community_membership/tests/test_membership_member_details.py @@ -0,0 +1,58 @@ +from datetime import date + +from dateutil.relativedelta import relativedelta + +from odoo.exceptions import ValidationError +from odoo.tests.common import TransactionCase, tagged + + +@tagged('post_install', '-at_install') +class TestMembershipMemberDetails(TransactionCase): + + def _tier(self, code, **values): + return self.env['community.membership.tier'].create({ + 'name': code, 'code': code, 'price': 10.0, 'period': 'annual', **values, + }) + + def test_fixed_term_tier_expires_after_term_years(self): + tier = self._tier('DET-10Y', period='fixed_term', term_years=10) + partner = self.env['res.partner'].create({'name': 'Ten Year Member', 'membership_tier_id': tier.id}) + partner.action_activate_membership() + self.assertEqual(partner.membership_expiry, partner.membership_start + relativedelta(years=10)) + + def test_name_built_from_first_and_last_name(self): + partner = self.env['res.partner'].create({'first_name': 'Arun', 'last_name': 'Kumar'}) + self.assertEqual(partner.name, 'Arun Kumar') + + def test_age_counts_birth_month(self): + partner = self.env['res.partner'].create({'name': 'Aged Member', 'birth_month': '6', 'birth_year': 1960}) + self.assertEqual(partner._age_on(date(2026, 5, 31)), 65) + self.assertEqual(partner._age_on(date(2026, 6, 1)), 66) + + def test_tier_minimum_age_enforced(self): + tier = self._tier('DET-SEN', min_age=65) + this_year = date.today().year + with self.assertRaises(ValidationError): + self.env['res.partner'].create({ + 'name': 'Too Young', 'membership_tier_id': tier.id, + 'birth_month': '1', 'birth_year': this_year - 30, + }) + # Unknown birth year is allowed; the board can check documents instead. + self.env['res.partner'].create({'name': 'Unknown Age', 'membership_tier_id': tier.id}) + + def test_birth_year_must_be_plausible(self): + with self.assertRaises(ValidationError): + self.env['res.partner'].create({'name': 'Bad Year', 'birth_year': 1850}) + + def test_family_members_listed_on_head(self): + tier = self._tier('DET-FAM', allows_family_members=True) + head = self.env['res.partner'].create({'name': 'Family Head', 'membership_tier_id': tier.id}) + spouse = self.env['res.partner'].create({'name': 'Spouse', 'family_head_id': head.id}) + self.assertEqual(head.family_member_ids, spouse) + + def test_board_approval(self): + partner = self.env['res.partner'].create({ + 'name': 'Applicant', 'membership_approval_state': 'pending', + }) + partner.action_approve_membership() + self.assertEqual(partner.membership_approval_state, 'approved') diff --git a/addons/community_membership/views/membership_tier_views.xml b/addons/community_membership/views/membership_tier_views.xml index 011fb21..35b14e4 100644 --- a/addons/community_membership/views/membership_tier_views.xml +++ b/addons/community_membership/views/membership_tier_views.xml @@ -11,6 +11,8 @@ + + @@ -32,6 +34,8 @@ + @@ -39,6 +43,17 @@ + + + + + + + + + + diff --git a/addons/community_membership/views/res_partner_views.xml b/addons/community_membership/views/res_partner_views.xml index 1f568fa..f6d6fbf 100644 --- a/addons/community_membership/views/res_partner_views.xml +++ b/addons/community_membership/views/res_partner_views.xml @@ -8,22 +8,67 @@ - + + + + + + + + + + + + + - + -