Add member application fields and the six real TNCSC membership plans
Some checks failed
CI / Brand-leak check (push) Has been cancelled
CI / flake8 / pylint-odoo / manifest completeness (push) Has been cancelled
CI / Install all modules with --test-enable (push) Has been cancelled

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 <noreply@anthropic.com>
This commit is contained in:
metatroncubeswdev 2026-09-27 10:46:31 -04:00
parent df80bc9560
commit 657792b268
18 changed files with 462 additions and 52 deletions

3
.gitignore vendored
View File

@ -17,3 +17,6 @@ data/raw/*
*.csv.bak *.csv.bak
.env .env
# Local MCP config (contains credentials)
.mcp.json

View File

@ -1,6 +1,15 @@
Changelog 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) 19.0.1.0.0 (2026-08-17)
~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~
* Initial release: configurable membership tiers, ``res.partner`` extension * Initial release: configurable membership tiers, ``res.partner`` extension

View File

@ -1,6 +1,6 @@
{ {
'name': 'Community OS - Membership', 'name': 'Community OS - Membership',
'version': '19.0.1.0.0', 'version': '19.0.1.1.0',
'category': 'Sales/Members', 'category': 'Sales/Members',
'summary': 'Configurable membership tiers, family grouping, renewals, QR membership card and self-service portal', 'summary': 'Configurable membership tiers, family grouping, renewals, QR membership card and self-service portal',
'description': """ 'description': """

View File

@ -1,3 +1,5 @@
from dateutil.relativedelta import relativedelta
from odoo import api, fields, models from odoo import api, fields, models
PERIOD_DURATION_DAYS = { PERIOD_DURATION_DAYS = {
@ -24,9 +26,30 @@ class CommunityMembershipTier(models.Model):
('annual', 'Annual'), ('annual', 'Annual'),
('one_time', 'One-time (lifetime)'), ('one_time', 'One-time (lifetime)'),
('monthly', 'Monthly'), ('monthly', 'Monthly'),
('fixed_term', 'Fixed term (years)'),
], ],
default='annual', required=True, 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( member_state_logic = fields.Text(
string='State Notes', string='State Notes',
help="Free-text notes for admins on any special membership-state handling " help="Free-text notes for admins on any special membership-state handling "
@ -42,6 +65,10 @@ class CommunityMembershipTier(models.Model):
_code_uniq = models.Constraint( _code_uniq = models.Constraint(
'unique(code)', 'A membership tier with this code already exists.', '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 @api.model_create_multi
def create(self, vals_list): def create(self, vals_list):
@ -78,3 +105,11 @@ class CommunityMembershipTier(models.Model):
def duration_days(self): def duration_days(self):
self.ensure_one() self.ensure_one()
return PERIOD_DURATION_DAYS.get(self.period) 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

View File

@ -2,13 +2,20 @@ import base64
import io import io
import qrcode 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' MEMBER_ID_SEQUENCE_CODE = 'community.membership.member_id'
DEFAULT_MEMBER_ID_FORMAT = 'MEM-{year}-{seq}' DEFAULT_MEMBER_ID_FORMAT = 'MEM-{year}-{seq}'
DEFAULT_RENEWAL_OFFSETS = '30,14,7' 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): class ResPartner(models.Model):
@ -35,11 +42,98 @@ class ResPartner(models.Model):
domain=[('membership_tier_id', '!=', False)], domain=[('membership_tier_id', '!=', False)],
help="If this member belongs to a family membership, the primary member it is grouped under.", 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') is_volunteer = fields.Boolean(string='Volunteer')
volunteer_hours = fields.Float(string='Volunteer Hours') volunteer_hours = fields.Float(string='Volunteer Hours')
membership_org_name = fields.Char(compute='_compute_membership_org_name') membership_org_name = fields.Char(compute='_compute_membership_org_name')
membership_card_qr = fields.Binary(compute='_compute_membership_card_qr') 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): def _compute_membership_org_name(self):
org_name = self.env['ir.config_parameter'].sudo().get_param('community_membership.org_name') or '' org_name = self.env['ir.config_parameter'].sudo().get_param('community_membership.org_name') or ''
for partner in self: for partner in self:
@ -74,9 +168,9 @@ class ResPartner(models.Model):
vals['membership_member_id'] = partner._generate_member_id() vals['membership_member_id'] = partner._generate_member_id()
if not partner.membership_start: if not partner.membership_start:
vals['membership_start'] = today vals['membership_start'] = today
duration_days = partner.membership_tier_id.duration_days() expiry = partner.membership_tier_id.get_expiry_date(today)
if duration_days: if expiry:
vals['membership_expiry'] = today + relativedelta(days=duration_days) vals['membership_expiry'] = expiry
partner.write(vals) partner.write(vals)
return True return True

View File

@ -1,3 +1,4 @@
from . import test_membership from . import test_membership
from . import test_membership_renewal from . import test_membership_renewal
from . import test_membership_verify from . import test_membership_verify
from . import test_membership_member_details

View File

@ -7,7 +7,7 @@ class TestMembership(TransactionCase):
def test_tier_creates_product(self): def test_tier_creates_product(self):
tier = self.env['community.membership.tier'].create({ tier = self.env['community.membership.tier'].create({
'name': 'Individual', 'name': 'Individual',
'code': 'IND', 'code': 'TEST-IND',
'price': 50.0, 'price': 50.0,
'period': 'annual', 'period': 'annual',
}) })
@ -15,6 +15,10 @@ class TestMembership(TransactionCase):
self.assertEqual(tier.product_id.list_price, 50.0) self.assertEqual(tier.product_id.list_price, 50.0)
def test_member_id_generated_on_activation(self): 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({ tier = self.env['community.membership.tier'].create({
'name': 'Individual', 'name': 'Individual',
'code': 'IND2', 'code': 'IND2',
@ -39,7 +43,7 @@ class TestMembership(TransactionCase):
def test_one_time_tier_never_expires(self): def test_one_time_tier_never_expires(self):
tier = self.env['community.membership.tier'].create({ tier = self.env['community.membership.tier'].create({
'name': 'Lifetime', 'name': 'Lifetime',
'code': 'LIFE', 'code': 'TEST-LIFE',
'price': 500.0, 'price': 500.0,
'period': 'one_time', 'period': 'one_time',
}) })

View File

@ -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')

View File

@ -11,6 +11,8 @@
<field name="price" widget="monetary"/> <field name="price" widget="monetary"/>
<field name="currency_id" column_invisible="1"/> <field name="currency_id" column_invisible="1"/>
<field name="period"/> <field name="period"/>
<field name="term_years" invisible="period != 'fixed_term'"/>
<field name="min_age" optional="show"/>
<field name="active" column_invisible="1"/> <field name="active" column_invisible="1"/>
</list> </list>
</field> </field>
@ -32,6 +34,8 @@
<field name="price" widget="monetary"/> <field name="price" widget="monetary"/>
<field name="currency_id" groups="base.group_multi_currency"/> <field name="currency_id" groups="base.group_multi_currency"/>
<field name="period"/> <field name="period"/>
<field name="term_years" invisible="period != 'fixed_term'"
required="period == 'fixed_term'"/>
</group> </group>
<group> <group>
<field name="sequence"/> <field name="sequence"/>
@ -39,6 +43,17 @@
<field name="product_id" readonly="1"/> <field name="product_id" readonly="1"/>
</group> </group>
</group> </group>
<group string="Eligibility">
<group>
<field name="min_age"/>
<field name="requires_proof"/>
<field name="allows_family_members"/>
</group>
</group>
<group string="Description">
<field name="description" nolabel="1"
placeholder="Who this tier is for, shown to applicants on the signup page..."/>
</group>
<group string="Notes"> <group string="Notes">
<field name="member_state_logic" nolabel="1" placeholder="Optional admin notes on state handling for this tier..."/> <field name="member_state_logic" nolabel="1" placeholder="Optional admin notes on state handling for this tier..."/>
</group> </group>

View File

@ -8,22 +8,67 @@
<notebook position="inside"> <notebook position="inside">
<page string="Membership" name="membership"> <page string="Membership" name="membership">
<group> <group>
<group string="Member">
<field name="first_name"/>
<field name="last_name"/>
<label for="birth_month" string="Month / Year of Birth"
groups="community_membership.group_membership_manager"/>
<div class="o_row" groups="community_membership.group_membership_manager">
<field name="birth_month" placeholder="Month"/>
<field name="birth_year" placeholder="Year" options="{'format': false}"/>
</div>
<field name="member_age" invisible="not birth_year"
groups="community_membership.group_membership_manager"/>
</group>
<group string="Application">
<field name="membership_approval_state"/>
<field name="membership_terms_accepted"/>
<field name="membership_proof_document" filename="membership_proof_filename"
groups="community_membership.group_membership_manager"/>
<field name="membership_proof_filename" invisible="1"
groups="community_membership.group_membership_manager"/>
</group>
</group>
<group> <group>
<group string="Membership">
<field name="membership_member_id"/> <field name="membership_member_id"/>
<field name="membership_tier_id"/> <field name="membership_tier_id"/>
<field name="membership_state"/> <field name="membership_state"/>
<field name="family_head_id"/> <field name="family_head_id"/>
</group> </group>
<group> <group string="Dates">
<field name="membership_start"/> <field name="membership_start"/>
<field name="membership_expiry"/> <field name="membership_expiry"/>
<field name="is_volunteer"/> <field name="is_volunteer"/>
<field name="volunteer_hours" invisible="not is_volunteer"/> <field name="volunteer_hours" invisible="not is_volunteer"/>
</group> </group>
</group> </group>
<group name="eligibility" string="Eligibility"
groups="community_membership.group_membership_manager"/>
<separator string="Family Members" invisible="family_head_id"/>
<field name="family_member_ids" invisible="family_head_id"
context="{'default_family_head_id': id}">
<list editable="bottom">
<field name="first_name"/>
<field name="last_name"/>
<field name="name" column_invisible="1"/>
<field name="email" optional="show"/>
<field name="phone" optional="hide"/>
</list>
</field>
<div class="d-flex gap-2 mt-3">
<button name="action_approve_membership" type="object"
string="Approve" class="btn-secondary"
groups="community_membership.group_membership_manager"
invisible="membership_approval_state != 'pending'"/>
<button name="action_reject_membership" type="object"
string="Reject" class="btn-secondary"
groups="community_membership.group_membership_manager"
invisible="membership_approval_state != 'pending'"/>
<button name="action_activate_membership" type="object" <button name="action_activate_membership" type="object"
string="Activate Membership" class="btn-primary" string="Activate Membership" class="btn-primary"
invisible="membership_state == 'active'"/> invisible="membership_state == 'active'"/>
</div>
</page> </page>
</notebook> </notebook>
</field> </field>
@ -53,6 +98,8 @@
<filter string="Active Members" name="membership_active" domain="[('membership_state', '=', 'active')]"/> <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="Renewal Due" name="membership_renewal_due" domain="[('membership_state', '=', 'renewal_due')]"/>
<filter string="Expired Members" name="membership_expired" domain="[('membership_state', '=', 'expired')]"/> <filter string="Expired Members" name="membership_expired" domain="[('membership_state', '=', 'expired')]"/>
<filter string="Pending Approval" name="membership_pending_approval"
domain="[('membership_approval_state', '=', 'pending')]"/>
<group name="group_by" position="inside"> <group name="group_by" position="inside">
<filter string="Membership Tier" name="group_by_tier" context="{'group_by': 'membership_tier_id'}"/> <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'}"/> <filter string="Membership State" name="group_by_state" context="{'group_by': 'membership_state'}"/>

View File

@ -1,6 +1,6 @@
{ {
'name': 'TNCSC Deployment', 'name': 'TNCSC Deployment',
'version': '19.0.1.0.0', 'version': '19.0.1.1.0',
'category': 'Hidden', 'category': 'Hidden',
'summary': 'Tamil Nadu Cultural Society of Canada - client configuration for the Community OS suite', 'summary': 'Tamil Nadu Cultural Society of Canada - client configuration for the Community OS suite',
'description': """ 'description': """
@ -44,6 +44,7 @@ that applies to the community_* product modules.
'data/mail_template_overrides.xml', 'data/mail_template_overrides.xml',
'data/events_data.xml', 'data/events_data.xml',
'data/ir_cron.xml', 'data/ir_cron.xml',
'views/res_partner_views.xml',
'views/website_pages.xml', 'views/website_pages.xml',
'views/website_header_overrides.xml', 'views/website_header_overrides.xml',
'views/browser_branding.xml', 'views/browser_branding.xml',

View File

@ -10,46 +10,68 @@
<function model="ir.config_parameter" name="set_param" <function model="ir.config_parameter" name="set_param"
eval="('community_membership.renewal_offsets', '30,14,7')"/> eval="('community_membership.renewal_offsets', '30,14,7')"/>
<!-- Membership tiers (CAD) - figures from the implementation plan; <!-- Membership tiers (CAD), matching tncsc.com/membership-registration
update via Settings once TNCSC confirms final pricing. --> (checked 2026-09-27). Every tier is subject to Board approval.
<record id="membership_tier_individual" model="community.membership.tier"> Existing databases get these values from
<field name="name">Individual</field> migrations/19.0.1.1.0/post-migrate.py, since noupdate="1" stops
<field name="code">IND</field> upgrades from rewriting them. -->
<field name="price">50.0</field>
<field name="currency_id" ref="base.CAD"/>
<field name="period">annual</field>
<field name="sequence">10</field>
</record>
<record id="membership_tier_family" model="community.membership.tier"> <record id="membership_tier_family" model="community.membership.tier">
<field name="name">Family</field> <field name="name">Annual/Family</field>
<field name="code">FAM</field> <field name="code">FAM</field>
<field name="price">80.0</field>
<field name="currency_id" ref="base.CAD"/>
<field name="period">annual</field>
<field name="sequence">20</field>
</record>
<record id="membership_tier_student" model="community.membership.tier">
<field name="name">Student</field>
<field name="code">STU</field>
<field name="price">20.0</field>
<field name="currency_id" ref="base.CAD"/>
<field name="period">annual</field>
<field name="sequence">30</field>
</record>
<record id="membership_tier_senior" model="community.membership.tier">
<field name="name">Senior</field>
<field name="code">SEN</field>
<field name="price">30.0</field> <field name="price">30.0</field>
<field name="currency_id" ref="base.CAD"/> <field name="currency_id" ref="base.CAD"/>
<field name="period">annual</field> <field name="period">annual</field>
<field name="sequence">40</field> <field name="allows_family_members" eval="True"/>
<field name="description">Covers the member, his/her spouse and kids living at the same address.</field>
<field name="sequence">10</field>
</record>
<record id="membership_tier_long_term" model="community.membership.tier">
<field name="name">Long-Term Membership</field>
<field name="code">LONG</field>
<field name="price">250.0</field>
<field name="currency_id" ref="base.CAD"/>
<field name="period">fixed_term</field>
<field name="term_years">10</field>
<field name="sequence">20</field>
</record> </record>
<record id="membership_tier_life" model="community.membership.tier"> <record id="membership_tier_life" model="community.membership.tier">
<field name="name">Life</field> <field name="name">Life Term Membership</field>
<field name="code">LIFE</field> <field name="code">LIFE</field>
<field name="price">500.0</field> <field name="price">500.0</field>
<field name="currency_id" ref="base.CAD"/> <field name="currency_id" ref="base.CAD"/>
<field name="period">one_time</field> <field name="period">one_time</field>
<field name="sequence">30</field>
</record>
<record id="membership_tier_senior" model="community.membership.tier">
<field name="name">Senior Annual Membership</field>
<field name="code">SEN</field>
<field name="price">15.0</field>
<field name="currency_id" ref="base.CAD"/>
<field name="period">annual</field>
<field name="min_age">65</field>
<field name="requires_proof" eval="True"/>
<field name="description">For seniors over 65 years. Documents may be requested to confirm.</field>
<field name="sequence">40</field>
</record>
<record id="membership_tier_senior_life" model="community.membership.tier">
<field name="name">Senior Life Term</field>
<field name="code">SENLIFE</field>
<field name="price">250.0</field>
<field name="currency_id" ref="base.CAD"/>
<field name="period">one_time</field>
<field name="min_age">60</field>
<field name="requires_proof" eval="True"/>
<field name="description">Life term membership for seniors aged 60 and over. Documents may be requested to confirm.</field>
<field name="sequence">50</field> <field name="sequence">50</field>
</record> </record>
<record id="membership_tier_student" model="community.membership.tier">
<field name="name">Student Annual Membership</field>
<field name="code">STU</field>
<field name="price">10.0</field>
<field name="currency_id" ref="base.CAD"/>
<field name="period">annual</field>
<field name="requires_proof" eval="True"/>
<field name="description">For full-time students. Documents may be requested to confirm.</field>
<field name="sequence">60</field>
</record>
</odoo> </odoo>

View File

@ -0,0 +1,42 @@
"""Bring an existing database's membership tiers in line with the six plans
on tncsc.com/membership-registration.
data/membership_data.xml is noupdate="1", so an upgrade creates the new
tiers (Long-Term, Senior Life Term) but leaves existing ones untouched.
This script rewrites those, and archives the old "Individual" tier, which
TNCSC doesn't offer. Archiving, not deleting, keeps it on any member
already holding it.
"""
from odoo import SUPERUSER_ID, api
TIER_VALUES = {
'membership_tier_family': {
'name': 'Annual/Family', 'price': 30.0, 'period': 'annual', 'sequence': 10,
'allows_family_members': True, 'min_age': 0, 'requires_proof': False,
'description': 'Covers the member, his/her spouse and kids living at the same address.',
},
'membership_tier_life': {
'name': 'Life Term Membership', 'price': 500.0, 'period': 'one_time', 'sequence': 30,
},
'membership_tier_senior': {
'name': 'Senior Annual Membership', 'price': 15.0, 'period': 'annual', 'sequence': 40,
'min_age': 65, 'requires_proof': True,
'description': 'For seniors over 65 years. Documents may be requested to confirm.',
},
'membership_tier_student': {
'name': 'Student Annual Membership', 'price': 10.0, 'period': 'annual', 'sequence': 60,
'requires_proof': True,
'description': 'For full-time students. Documents may be requested to confirm.',
},
}
def migrate(cr, version):
env = api.Environment(cr, SUPERUSER_ID, {})
for xmlid, values in TIER_VALUES.items():
tier = env.ref(f'tncsc_deployment.{xmlid}', raise_if_not_found=False)
if tier:
tier.write(values)
individual = env.ref('tncsc_deployment.membership_tier_individual', raise_if_not_found=False)
if individual:
individual.active = False

View File

@ -1 +1,2 @@
from . import res_company from . import res_company
from . import res_partner

View File

@ -0,0 +1,36 @@
from odoo import fields, models
MANAGER_GROUP = 'community_membership.group_membership_manager'
YES_NO = [('yes', 'Yes'), ('no', 'No')]
class ResPartner(models.Model):
"""TNCSC membership eligibility answers, asked on the registration form.
Stored as Yes/No selections rather than booleans so an unanswered
question stays distinguishable from a "No".
"""
_inherit = 'res.partner'
tncsc_born_tamil_nadu = fields.Selection(
YES_NO, string='Born in / Origin of Tamil Nadu', groups=MANAGER_GROUP,
help='Are you "born in Tamil Nadu, India" or born in any part of India but origin of Tamil Nadu?',
)
tncsc_pio_oci = fields.Selection(
YES_NO, string='Holds / Qualifies for PIO or OCI', groups=MANAGER_GROUP,
help='Are you the holder of Indian PIO/OCI or qualified to obtain one?',
)
tncsc_tamil_speaker_resident = fields.Selection(
YES_NO, string='Speaks Tamil, Lived in Tamil Nadu', groups=MANAGER_GROUP,
help='Do you speak Tamil and ever lived in Tamil Nadu, India with Indian citizenship?',
)
tncsc_family_tamil_indian = fields.Selection(
YES_NO, string='Spouse / Parent Tamil & Indian', groups=MANAGER_GROUP,
help='Does your spouse or either of your parents speak Tamil and have Indian citizenship, '
'or ever had Indian citizenship before migrating to Canada?',
)
tncsc_other_citizenship = fields.Selection(
YES_NO, string='Former Citizenship (Neighbouring Country)', groups=MANAGER_GROUP,
help='Had you ever been a citizen of Afghanistan, Bangladesh, Bhutan, China, Nepal, '
'Pakistan or Sri Lanka?',
)

View File

@ -25,19 +25,37 @@ class TestTncscDeployment(TransactionCase):
self.assertEqual(get_param('community_membership.org_name'), 'Tamil Nadu Cultural Society of Canada') self.assertEqual(get_param('community_membership.org_name'), 'Tamil Nadu Cultural Society of Canada')
self.assertEqual(get_param('community_membership.member_id_format'), 'TNCSC-{year}-{seq}') self.assertEqual(get_param('community_membership.member_id_format'), 'TNCSC-{year}-{seq}')
def test_five_membership_tiers_seeded(self): def test_six_membership_tiers_seeded(self):
tiers = self.env['community.membership.tier'].search([]) tiers = self.env['community.membership.tier'].search([])
self.assertEqual(len(tiers), 5) self.assertEqual(len(tiers), 6)
by_code = {tier.code: tier for tier in tiers} by_code = {tier.code: tier for tier in tiers}
self.assertEqual(by_code['IND'].price, 50.0) self.assertEqual(by_code['FAM'].price, 30.0)
self.assertEqual(by_code['FAM'].price, 80.0) self.assertTrue(by_code['FAM'].allows_family_members)
self.assertEqual(by_code['STU'].price, 20.0) self.assertEqual(by_code['LONG'].price, 250.0)
self.assertEqual(by_code['SEN'].price, 30.0) self.assertEqual((by_code['LONG'].period, by_code['LONG'].term_years), ('fixed_term', 10))
self.assertEqual(by_code['LIFE'].price, 500.0) self.assertEqual(by_code['LIFE'].price, 500.0)
self.assertEqual(by_code['SEN'].price, 15.0)
self.assertEqual(by_code['SEN'].min_age, 65)
self.assertEqual(by_code['SENLIFE'].price, 250.0)
self.assertEqual((by_code['SENLIFE'].period, by_code['SENLIFE'].min_age), ('one_time', 60))
self.assertEqual(by_code['STU'].price, 10.0)
self.assertTrue(by_code['STU'].requires_proof)
for tier in tiers: for tier in tiers:
self.assertEqual(tier.currency_id.name, 'CAD') self.assertEqual(tier.currency_id.name, 'CAD')
self.assertTrue(tier.product_id, "Every seeded tier should still auto-create its product") self.assertTrue(tier.product_id, "Every seeded tier should still auto-create its product")
def test_eligibility_answers_stored(self):
partner = self.env['res.partner'].create({
'first_name': 'Kavya',
'last_name': 'Raman',
'tncsc_born_tamil_nadu': 'yes',
'tncsc_other_citizenship': 'no',
})
self.assertEqual(partner.name, 'Kavya Raman')
self.assertEqual(partner.tncsc_born_tamil_nadu, 'yes')
self.assertEqual(partner.tncsc_other_citizenship, 'no')
self.assertFalse(partner.tncsc_pio_oci, "Unanswered questions should stay empty, not 'no'")
def test_chart_of_accounts_loaded(self): def test_chart_of_accounts_loaded(self):
company = self.env.company company = self.env.company
self.assertEqual(company.currency_id.name, 'CAD') self.assertEqual(company.currency_id.name, 'CAD')

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_partner_form_tncsc_eligibility" model="ir.ui.view">
<field name="name">res.partner.form.tncsc.eligibility</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="community_membership.view_partner_form_membership"/>
<field name="arch" type="xml">
<group name="eligibility" position="inside">
<group>
<field name="tncsc_born_tamil_nadu" widget="radio" options="{'horizontal': true}"/>
<field name="tncsc_pio_oci" widget="radio" options="{'horizontal': true}"/>
<field name="tncsc_tamil_speaker_resident" widget="radio" options="{'horizontal': true}"/>
</group>
<group>
<field name="tncsc_family_tamil_indian" widget="radio" options="{'horizontal': true}"/>
<field name="tncsc_other_citizenship" widget="radio" options="{'horizontal': true}"/>
</group>
</group>
</field>
</record>
</odoo>

View File

@ -17,8 +17,9 @@ Behaviour, confirmed with TNCSC before writing this:
- Rows whose email is on the @abbuzz.com test/spam domain are skipped - Rows whose email is on the @abbuzz.com test/spam domain are skipped
entirely (not real members). entirely (not real members).
- "Level" maps to a tier code: Annual/Family -> FAM, Long-Term - "Level" maps to a tier code: Annual/Family -> FAM, Long-Term
Membership -> LIFE, Student Annual Membership -> STU, Senior Annual Membership -> LONG, Life Term Membership -> LIFE, Student Annual
Membership -> SEN. Blank Level, or a comma-separated multi-value Level Membership -> STU, Senior Annual Membership -> SEN, Senior Life Term ->
SENLIFE. Blank Level, or a comma-separated multi-value Level
(ambiguous - can't tell which one is current), is imported as a (ambiguous - can't tell which one is current), is imported as a
contact with NO tier set and flagged 'needs_review' in the report - contact with NO tier set and flagged 'needs_review' in the report -
never auto-guessed. never auto-guessed.
@ -70,9 +71,11 @@ COL_JOIN_DATE = 30
LEVEL_TO_TIER_CODE = { LEVEL_TO_TIER_CODE = {
'Annual/Family': 'FAM', 'Annual/Family': 'FAM',
'Long-Term Membership': 'LIFE', 'Long-Term Membership': 'LONG',
'Life Term Membership': 'LIFE',
'Student Annual Membership': 'STU', 'Student Annual Membership': 'STU',
'Senior Annual Membership': 'SEN', 'Senior Annual Membership': 'SEN',
'Senior Life Term': 'SENLIFE',
} }