Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
12255bb
feat(spp_change_request_v2): redesign Create Group CR (OP#876)
emjay0921 Jun 2, 2026
461a692
feat(spp_change_request_v2): redesign Add Member CR (OP#871)
emjay0921 Jun 2, 2026
139beb1
fix(change_request): Create Group CR QA round 1 (#876)
emjay0921 Jun 5, 2026
39994a5
Merge branch 'feat/876-create-group-cr-redesign' into feat/871-add-me…
emjay0921 Jun 5, 2026
c971274
fix(change_request): Add Member CR QA round 1 (#871)
emjay0921 Jun 5, 2026
736df21
fix(change_request): multi-phone capture for new group members (#876)
emjay0921 Jun 5, 2026
3fa86b6
Merge branch 'feat/876-create-group-cr-redesign' into feat/871-add-me…
emjay0921 Jun 5, 2026
1fe065b
fix(change_request): don't orphan phone rows when editing a new membe…
emjay0921 Jun 5, 2026
d9bac16
Merge branch 'feat/876-create-group-cr-redesign' into feat/871-add-me…
emjay0921 Jun 5, 2026
c303caa
fix(change_request): relax phone-row parent check to only reject mult…
emjay0921 Jun 5, 2026
6bc96b8
Merge branch 'feat/876-create-group-cr-redesign' into feat/871-add-me…
emjay0921 Jun 5, 2026
e766dd7
fix(change_request): drop Primary toggle from new-member phone list (…
emjay0921 Jun 5, 2026
ab8c3ef
Merge branch 'feat/876-create-group-cr-redesign' into feat/871-add-me…
emjay0921 Jun 5, 2026
c2e1b81
fix(change_request): stop default_detail_id leaking onto new-member p…
emjay0921 Jun 5, 2026
87dc4d1
fix(change_request): stop default_detail_id leaking onto new-member p…
emjay0921 Jun 5, 2026
e0471eb
fix(change_request): create phone records for new group members on ap…
emjay0921 Jun 5, 2026
e259b90
Merge branch 'feat/876-create-group-cr-redesign' into feat/871-add-me…
emjay0921 Jun 5, 2026
908cedc
feat(change_request): explicit head-of-household replacement on Add M…
emjay0921 Jun 8, 2026
1612822
fix(change_request): only show head-replacement info/toggle when Role…
emjay0921 Jun 8, 2026
a8c9b05
fix(change_request): replace head-replacement toggle with an info not…
emjay0921 Jun 8, 2026
73d5cf2
fix(change_request): show real data (not counts) on Create Group revi…
emjay0921 Jun 16, 2026
bd2c5b1
revert(change_request): drop head-of-household replacement on Add Mem…
emjay0921 Jun 16, 2026
fbb2ca9
Merge branch 'feat/876-create-group-cr-redesign' into feat/871-add-me…
emjay0921 Jun 16, 2026
e7a8d29
fix(change_request): show real data (not counts) on Add Member review…
emjay0921 Jun 16, 2026
d0d65b7
fix(change_request): hide Head role on Add Member when the group alre…
emjay0921 Jun 16, 2026
58d6033
feat(change_request_v2): redesign Change Head of Household CR (#873)
emjay0921 Jun 17, 2026
adbcc7f
feat(change_request_v2): Add Member CR selects an existing member (#871)
emjay0921 Jun 17, 2026
5a80c04
feat(change_request_v2): redesign Split Household CR (#877)
emjay0921 Jun 19, 2026
9682635
feat(change_request_v2): redesign Remove Member CR (#872)
emjay0921 Jun 22, 2026
123ca0f
feat(change_request_v2): capture new-member bank accounts in Create G…
emjay0921 Jun 22, 2026
6977feb
fix(change_request_v2): Add Member QA round — required member, head v…
emjay0921 Jun 22, 2026
8fa64f4
fix(change_request_v2): Change HoH follows New Role literally (#873)
emjay0921 Jun 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions spp_change_request_v2/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"views/detail_transfer_member_views.xml",
"views/detail_update_id_views.xml",
"views/detail_create_group_views.xml",
"views/create_group_member_wizard_views.xml",
"views/detail_merge_registrants_views.xml",
"views/detail_split_household_views.xml",
"views/preview_changes_wizard_views.xml",
Expand Down
193 changes: 148 additions & 45 deletions spp_change_request_v2/details/add_member.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
from odoo import api, fields, models
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
"""Detail model for the Add Member CR (OP#871).

The first page searches for an existing individual registrant and adds them to
the group with a role. (The earlier create-a-new-individual flow was replaced
per the updated #871 spec — Add Member now selects an existing member.)
"""

from odoo import _, api, fields, models
from odoo.exceptions import ValidationError


class SPPCRDetailAddMember(models.Model):
Expand All @@ -8,57 +17,151 @@ class SPPCRDetailAddMember(models.Model):
_description = "CR Detail: Add Group Member"
_inherit = ["spp.cr.detail.base", "mail.thread"]

# ══════════════════════════════════════════════════════════════════════════
# MEMBER INFORMATION - Real Odoo fields with full features
# ══════════════════════════════════════════════════════════════════════════
# ──────────────────────────────────────────────────────────────────────
# Target group identity (helps distinguish same-named groups — OP#871)
# ──────────────────────────────────────────────────────────────────────
registrant_registration_date = fields.Date(
related="change_request_id.registrant_id.registration_date",
string="Group Registration Date",
readonly=True,
)
registrant_id_display = fields.Char(
string="Group ID(s)",
compute="_compute_registrant_id_display",
help="The target group's registry ID type(s) and value(s).",
)

member_name = fields.Char(
string="Full Name",
# ──────────────────────────────────────────────────────────────────────
# Member to add (existing individual)
# ──────────────────────────────────────────────────────────────────────
individual_id = fields.Many2one(
"res.partner",
string="Member",
tracking=True,
help="Required before apply. Auto-computed from given/family names.",
help="Search for an existing individual registrant to add to the group.",
)
given_name = fields.Char(string="Given Name", tracking=True)
family_name = fields.Char(string="Family Name", tracking=True)
birthdate = fields.Date(string="Date of Birth", tracking=True)
gender_id = fields.Many2one(
# Domain string (computed) restricting the picker to existing individual
# registrants who are not already active members of the group. A computed
# domain is used instead of a materialised Many2many so the picker scales
# with a large registry (mirrors spp.change.request.registrant_domain).
individual_domain = fields.Char(compute="_compute_individual_domain")

# ──────────────────────────────────────────────────────────────────────
# Role (per-member, single row)
# ──────────────────────────────────────────────────────────────────────
membership_type_id = fields.Many2one(
"spp.vocabulary.code",
string="Gender",
domain="[('namespace_uri', '=', 'urn:iso:std:iso:5218')]",
string="Role",
domain="[('vocabulary_id.namespace_uri', '=', 'urn:openspp:vocab:group-membership-type')]",
tracking=True,
help="Role of the new member in the group. Optionality controlled by the CR type.",
)
relationship_id = fields.Many2one(
"spp.vocabulary.code",
string="Relationship to Head",
domain="[('vocabulary_id.namespace_uri', '=', 'urn:openspp:vocab:group-membership-type'),"
" ('code', '!=', 'head')]",
tracking=True,

# ──────────────────────────────────────────────────────────────────────
# Type-config mirrors (for view conditionals)
# ──────────────────────────────────────────────────────────────────────
type_requires_head = fields.Boolean(
related="change_request_id.request_type_id.requires_head",
string="Requires Head",
)
roles_available = fields.Boolean(
compute="_compute_roles_available",
string="Has Membership Roles",
help="True when the urn:openspp:vocab:group-membership-type vocabulary has any active code.",
)
id_number = fields.Char(string="ID Number", tracking=True)
phone = fields.Char(string="Phone Number", tracking=True)

# Reference to created individual (set after apply)
created_individual_id = fields.Many2one(
"res.partner",
string="Created Individual",
readonly=True,
# ──────────────────────────────────────────────────────────────────────
# Read-only context: existing members of the group
# ──────────────────────────────────────────────────────────────────────
existing_membership_ids = fields.Many2many(
"spp.group.membership",
string="Existing Members",
compute="_compute_existing_memberships",
)

# ══════════════════════════════════════════════════════════════════════════
# ONCHANGE - Full Odoo functionality
# ══════════════════════════════════════════════════════════════════════════

@api.onchange("given_name", "family_name")
def _onchange_names(self):
"""Auto-compute full name from given + family."""
if self.given_name or self.family_name:
name_vals = [
f"{self.family_name},"
if self.family_name and self.given_name
else f"{self.family_name}"
if self.family_name
else "",
self.given_name,
]

name = " ".join(filter(None, name_vals))
self.member_name = name.upper()
# ──────────────────────────────────────────────────────────────────────
# Computes
# ──────────────────────────────────────────────────────────────────────
def _active_member_individual_ids(self, group):
"""ids of individuals who are active members of the group."""
if not group or not group.is_group:
return []
memberships = self.env["spp.group.membership"].search([("group", "=", group.id), ("status", "=", "active")])
return memberships.mapped("individual").ids

@api.depends("change_request_id", "change_request_id.registrant_id")
def _compute_individual_domain(self):
for rec in self:
member_ids = rec._active_member_individual_ids(rec.change_request_id.registrant_id)
rec.individual_domain = str(
[
("is_registrant", "=", True),
("is_group", "=", False),
("id", "not in", member_ids),
]
)

@api.depends_context("uid")
def _compute_roles_available(self):
has_any = bool(
self.env["spp.vocabulary.code"].search_count(
[("vocabulary_id.namespace_uri", "=", "urn:openspp:vocab:group-membership-type")]
)
)
for rec in self:
rec.roles_available = has_any

@api.depends("change_request_id", "change_request_id.registrant_id")
def _compute_existing_memberships(self):
Membership = self.env["spp.group.membership"]
for rec in self:
group = rec.change_request_id.registrant_id
if group and group.is_group:
rec.existing_membership_ids = Membership.search([("group", "=", group.id), ("status", "=", "active")])
else:
rec.existing_membership_ids = Membership.browse([])

@api.depends("change_request_id.registrant_id.reg_ids")
def _compute_registrant_id_display(self):
"""Render the target group's registry IDs as 'Type: value; ...' so a
group can be told apart from others with the same name (OP#871)."""
for rec in self:
group = rec.change_request_id.registrant_id
ids = group.reg_ids if group and "reg_ids" in group._fields else False
rec.registrant_id_display = (
"; ".join(f"{i.id_type_as_str or ''}: {i.value or ''}".strip(": ") for i in ids) if ids else False
)

@api.constrains("membership_type_id")
def _check_single_head(self):
"""A group can have at most one Head of Household. Reject choosing the
Head role when the target group already has an active head — at save /
submit time, not just on apply (OP#871, uniform with the other CRs)."""
for rec in self:
role = rec.membership_type_id
group = rec.change_request_id.registrant_id
if not role or role.code != "head" or not group or not group.is_group:
continue
group_has_head = self.env["spp.group.membership"].search_count(
[
("group", "=", group.id),
("status", "=", "active"),
("membership_type_ids.code", "=", "head"),
]
)
if group_has_head:
raise ValidationError(_("This group already has a Head of Household. Only one member can be Head."))


class SPPGroupMembership(models.Model):
"""Expose the member's registration date for the Add Member CR's
Current Members table (OP#871) — membership start_date is the join date,
not the individual's registration date, so they are shown separately."""

_inherit = "spp.group.membership"

individual_registration_date = fields.Date(
related="individual.registration_date",
string="Registration Date",
readonly=True,
)
Loading
Loading