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 <noreply@anthropic.com>
This commit is contained in:
parent
df80bc9560
commit
657792b268
3
.gitignore
vendored
3
.gitignore
vendored
@ -17,3 +17,6 @@ data/raw/*
|
||||
*.csv.bak
|
||||
|
||||
.env
|
||||
|
||||
# Local MCP config (contains credentials)
|
||||
.mcp.json
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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': """
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
from . import test_membership
|
||||
from . import test_membership_renewal
|
||||
from . import test_membership_verify
|
||||
from . import test_membership_member_details
|
||||
|
||||
@ -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',
|
||||
})
|
||||
|
||||
@ -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')
|
||||
@ -11,6 +11,8 @@
|
||||
<field name="price" widget="monetary"/>
|
||||
<field name="currency_id" column_invisible="1"/>
|
||||
<field name="period"/>
|
||||
<field name="term_years" invisible="period != 'fixed_term'"/>
|
||||
<field name="min_age" optional="show"/>
|
||||
<field name="active" column_invisible="1"/>
|
||||
</list>
|
||||
</field>
|
||||
@ -32,6 +34,8 @@
|
||||
<field name="price" widget="monetary"/>
|
||||
<field name="currency_id" groups="base.group_multi_currency"/>
|
||||
<field name="period"/>
|
||||
<field name="term_years" invisible="period != 'fixed_term'"
|
||||
required="period == 'fixed_term'"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="sequence"/>
|
||||
@ -39,6 +43,17 @@
|
||||
<field name="product_id" readonly="1"/>
|
||||
</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">
|
||||
<field name="member_state_logic" nolabel="1" placeholder="Optional admin notes on state handling for this tier..."/>
|
||||
</group>
|
||||
|
||||
@ -8,22 +8,67 @@
|
||||
<notebook position="inside">
|
||||
<page string="Membership" name="membership">
|
||||
<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 string="Membership">
|
||||
<field name="membership_member_id"/>
|
||||
<field name="membership_tier_id"/>
|
||||
<field name="membership_state"/>
|
||||
<field name="family_head_id"/>
|
||||
</group>
|
||||
<group>
|
||||
<group string="Dates">
|
||||
<field name="membership_start"/>
|
||||
<field name="membership_expiry"/>
|
||||
<field name="is_volunteer"/>
|
||||
<field name="volunteer_hours" invisible="not is_volunteer"/>
|
||||
</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"
|
||||
string="Activate Membership" class="btn-primary"
|
||||
invisible="membership_state == 'active'"/>
|
||||
</div>
|
||||
</page>
|
||||
</notebook>
|
||||
</field>
|
||||
@ -53,6 +98,8 @@
|
||||
<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')]"/>
|
||||
<filter string="Pending Approval" name="membership_pending_approval"
|
||||
domain="[('membership_approval_state', '=', 'pending')]"/>
|
||||
<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'}"/>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
'name': 'TNCSC Deployment',
|
||||
'version': '19.0.1.0.0',
|
||||
'version': '19.0.1.1.0',
|
||||
'category': 'Hidden',
|
||||
'summary': 'Tamil Nadu Cultural Society of Canada - client configuration for the Community OS suite',
|
||||
'description': """
|
||||
@ -44,6 +44,7 @@ that applies to the community_* product modules.
|
||||
'data/mail_template_overrides.xml',
|
||||
'data/events_data.xml',
|
||||
'data/ir_cron.xml',
|
||||
'views/res_partner_views.xml',
|
||||
'views/website_pages.xml',
|
||||
'views/website_header_overrides.xml',
|
||||
'views/browser_branding.xml',
|
||||
|
||||
@ -10,46 +10,68 @@
|
||||
<function model="ir.config_parameter" name="set_param"
|
||||
eval="('community_membership.renewal_offsets', '30,14,7')"/>
|
||||
|
||||
<!-- Membership tiers (CAD) - figures from the implementation plan;
|
||||
update via Settings once TNCSC confirms final pricing. -->
|
||||
<record id="membership_tier_individual" model="community.membership.tier">
|
||||
<field name="name">Individual</field>
|
||||
<field name="code">IND</field>
|
||||
<field name="price">50.0</field>
|
||||
<field name="currency_id" ref="base.CAD"/>
|
||||
<field name="period">annual</field>
|
||||
<field name="sequence">10</field>
|
||||
</record>
|
||||
<!-- Membership tiers (CAD), matching tncsc.com/membership-registration
|
||||
(checked 2026-09-27). Every tier is subject to Board approval.
|
||||
Existing databases get these values from
|
||||
migrations/19.0.1.1.0/post-migrate.py, since noupdate="1" stops
|
||||
upgrades from rewriting them. -->
|
||||
<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="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="currency_id" ref="base.CAD"/>
|
||||
<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 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="price">500.0</field>
|
||||
<field name="currency_id" ref="base.CAD"/>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
@ -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
|
||||
@ -1 +1,2 @@
|
||||
from . import res_company
|
||||
from . import res_partner
|
||||
|
||||
36
addons/tncsc_deployment/models/res_partner.py
Normal file
36
addons/tncsc_deployment/models/res_partner.py
Normal 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?',
|
||||
)
|
||||
@ -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.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([])
|
||||
self.assertEqual(len(tiers), 5)
|
||||
self.assertEqual(len(tiers), 6)
|
||||
by_code = {tier.code: tier for tier in tiers}
|
||||
self.assertEqual(by_code['IND'].price, 50.0)
|
||||
self.assertEqual(by_code['FAM'].price, 80.0)
|
||||
self.assertEqual(by_code['STU'].price, 20.0)
|
||||
self.assertEqual(by_code['SEN'].price, 30.0)
|
||||
self.assertEqual(by_code['FAM'].price, 30.0)
|
||||
self.assertTrue(by_code['FAM'].allows_family_members)
|
||||
self.assertEqual(by_code['LONG'].price, 250.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['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:
|
||||
self.assertEqual(tier.currency_id.name, 'CAD')
|
||||
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):
|
||||
company = self.env.company
|
||||
self.assertEqual(company.currency_id.name, 'CAD')
|
||||
|
||||
21
addons/tncsc_deployment/views/res_partner_views.xml
Normal file
21
addons/tncsc_deployment/views/res_partner_views.xml
Normal 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>
|
||||
@ -17,8 +17,9 @@ Behaviour, confirmed with TNCSC before writing this:
|
||||
- Rows whose email is on the @abbuzz.com test/spam domain are skipped
|
||||
entirely (not real members).
|
||||
- "Level" maps to a tier code: Annual/Family -> FAM, Long-Term
|
||||
Membership -> LIFE, Student Annual Membership -> STU, Senior Annual
|
||||
Membership -> SEN. Blank Level, or a comma-separated multi-value Level
|
||||
Membership -> LONG, Life Term Membership -> LIFE, Student Annual
|
||||
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
|
||||
contact with NO tier set and flagged 'needs_review' in the report -
|
||||
never auto-guessed.
|
||||
@ -70,9 +71,11 @@ COL_JOIN_DATE = 30
|
||||
|
||||
LEVEL_TO_TIER_CODE = {
|
||||
'Annual/Family': 'FAM',
|
||||
'Long-Term Membership': 'LIFE',
|
||||
'Long-Term Membership': 'LONG',
|
||||
'Life Term Membership': 'LIFE',
|
||||
'Student Annual Membership': 'STU',
|
||||
'Senior Annual Membership': 'SEN',
|
||||
'Senior Life Term': 'SENLIFE',
|
||||
}
|
||||
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user