Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 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
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
159 changes: 117 additions & 42 deletions spp_change_request_v2/details/add_member.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
# 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


Expand All @@ -8,57 +16,124 @@ 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
# ══════════════════════════════════════════════════════════════════════════

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.",
)

# ──────────────────────────────────────────────────────────────────────
# 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.",
)
relationship_id = fields.Many2one(
allowed_role_ids = fields.Many2many(
"spp.vocabulary.code",
string="Relationship to Head",
domain="[('vocabulary_id.namespace_uri', '=', 'urn:openspp:vocab:group-membership-type'),"
" ('code', '!=', 'head')]",
tracking=True,
string="Allowed Roles",
compute="_compute_allowed_role_ids",
help="Roles selectable for the new member; the Head role is excluded when "
"the group already has a Head of Household (OP#871).",
)
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
# ══════════════════════════════════════════════════════════════════════════
# ──────────────────────────────────────────────────────────────────────
# 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.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,
]
@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([])

name = " ".join(filter(None, name_vals))
self.member_name = name.upper()
@api.depends("change_request_id", "change_request_id.registrant_id")
def _compute_allowed_role_ids(self):
"""Restrict the Role selection: a group can have only one Head, so the
Head role is removed from the options when the target group already has
an active Head of Household (OP#871)."""
Code = self.env["spp.vocabulary.code"]
Membership = self.env["spp.group.membership"]
all_roles = Code.search([("vocabulary_id.namespace_uri", "=", "urn:openspp:vocab:group-membership-type")])
for rec in self:
roles = all_roles
group = rec.change_request_id.registrant_id
if group and group.is_group:
group_has_head = bool(
Membership.search_count(
[
("group", "=", group.id),
("status", "=", "active"),
("membership_type_ids.code", "=", "head"),
]
)
)
if group_has_head:
roles = all_roles.filtered(lambda r: r.code != "head")
rec.allowed_role_ids = roles
151 changes: 76 additions & 75 deletions spp_change_request_v2/details/change_hoh.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
from odoo import api, fields, models
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError

ROLE_NAMESPACE = "urn:openspp:vocab:group-membership-type"
HEAD_ROLE_CODE = "head"


class SPPCRDetailChangeHOH(models.Model):
"""Detail model for Change Head of Household CR type."""
"""Detail model for Change Head of Household CR type (OP#873)."""

_name = "spp.cr.detail.change_hoh"
_description = "CR Detail: Change Head of Household"
Expand All @@ -19,51 +23,26 @@ class SPPCRDetailChangeHOH(models.Model):
store=True,
readonly=True,
)
available_individual_ids = fields.Many2many(
"res.partner",
string="Available Individuals",
compute="_compute_available_individuals",
help="Active members excluding current head of household",
)
new_head_id = fields.Many2one(
"res.partner",
string="New Head of Household",
tracking=True,
domain="[('is_group', '=', False), ('is_registrant', '=', True)]",
help="Select the individual who will become the new head of household",
)
new_head_membership_id = fields.Many2one(
"spp.group.membership",
string="New Head Membership",
readonly=True,
help="Membership record for the new head (automatically set)",
)
previous_head_new_role_id = fields.Many2one(
"spp.vocabulary.code",
string="Previous Head's New Role",
domain="[('vocabulary_id.namespace_uri', '=', 'urn:openspp:vocab:group-membership-type'),"
" ('code', '!=', 'head')]",
tracking=True,
help="The new role for the previous head (e.g., Spouse, Other Adult)",
# One editable role line per active group member. The new head is whichever
# member is assigned the Head role (OP#873 — replaces the single new-head
# dropdown with a members-with-roles table).
member_line_ids = fields.One2many(
"spp.cr.detail.change_hoh.member",
"detail_id",
string="Members",
)
reason = fields.Selection(
[
("deceased", "Head Deceased"),
("incapacitated", "Head Incapacitated"),
("left_household", "Head Left Household"),
("age_change", "Age-based Change"),
("voluntary", "Voluntary Transfer"),
("correction", "Data Correction"),
("other", "Other"),
],
string="Reason for Change",
tracking=True,
)
effective_date = fields.Date(
string="Effective Date",
default=fields.Date.today,
tracking=True,
)
remarks = fields.Text(string="Remarks", tracking=True)

# ══════════════════════════════════════════════════════════════════════════
Expand All @@ -73,58 +52,80 @@ class SPPCRDetailChangeHOH(models.Model):
@api.depends("change_request_id.registrant_id")
def _compute_current_head(self):
"""Find the current head of household."""
head_kind = self.env["spp.vocabulary.code"].get_code("urn:openspp:vocab:group-membership-type", "head")
head_kind = self.env["spp.vocabulary.code"].get_code(ROLE_NAMESPACE, HEAD_ROLE_CODE)
for rec in self:
current_head = False
if rec.change_request_id.registrant_id and head_kind:
memberships = self.env["spp.group.membership"].search(
membership = self.env["spp.group.membership"].search(
[
("group", "=", rec.change_request_id.registrant_id.id),
("membership_type_ids", "in", [head_kind.id]),
("status", "=", "active"),
],
limit=1,
)
if memberships:
current_head = memberships.individual
if membership:
current_head = membership.individual
rec.current_head_id = current_head

@api.depends("change_request_id.registrant_id", "current_head_id")
def _compute_available_individuals(self):
"""Compute available individuals excluding current head of household."""
head_kind = self.env["spp.vocabulary.code"].get_code("urn:openspp:vocab:group-membership-type", "head")
for rec in self:
available_individuals = self.env["res.partner"]
if rec.change_request_id.registrant_id:
# Get all active memberships for this group
all_memberships = self.env["spp.group.membership"].search(
[
("group", "=", rec.change_request_id.registrant_id.id),
("status", "=", "active"),
]
)
# Exclude current head
if head_kind:
available_memberships = all_memberships.filtered(lambda m: head_kind not in m.membership_type_ids)
else:
available_memberships = all_memberships
# Get the individuals from these memberships
available_individuals = available_memberships.mapped("individual")
rec.available_individual_ids = available_individuals
@api.model_create_multi
def create(self, vals_list):
details = super().create(vals_list)
for detail in details:
if not detail.member_line_ids:
detail._seed_member_lines()
return details

@api.onchange("new_head_id")
def _onchange_new_head_id(self):
"""Set the membership_id when individual is selected."""
self.new_head_membership_id = False
if self.new_head_id and self.change_request_id.registrant_id:
# Find the membership for this individual in this group
membership = self.env["spp.group.membership"].search(
[
("group", "=", self.change_request_id.registrant_id.id),
("individual", "=", self.new_head_id.id),
("status", "=", "active"),
],
limit=1,
def _seed_member_lines(self):
"""Populate one editable role line per active group member, defaulting
each member's new role to their current role."""
self.ensure_one()
group = self.change_request_id.registrant_id
if not group or not group.is_group:
return
memberships = self.env["spp.group.membership"].search([("group", "=", group.id), ("status", "=", "active")])
lines = []
for membership in memberships:
current_roles = membership.membership_type_ids
lines.append(
(
0,
0,
{
"individual_id": membership.individual.id,
"membership_id": membership.id,
"old_role_display": ", ".join(current_roles.mapped("display")),
"new_role_id": current_roles[:1].id if current_roles else False,
},
)
)
if membership:
self.new_head_membership_id = membership
self.member_line_ids = lines


class SPPCRDetailChangeHOHMember(models.Model):
"""One editable role line per current group member (Change HoH, OP#873)."""

_name = "spp.cr.detail.change_hoh.member"
_description = "CR Detail: Change HoH - Member Role"

detail_id = fields.Many2one(
"spp.cr.detail.change_hoh",
required=True,
ondelete="cascade",
)
individual_id = fields.Many2one("res.partner", string="Member", readonly=True)
membership_id = fields.Many2one("spp.group.membership", readonly=True)
old_role_display = fields.Char(string="Current Role", readonly=True)
new_role_id = fields.Many2one(
"spp.vocabulary.code",
string="New Role",
domain="[('vocabulary_id.namespace_uri', '=', 'urn:openspp:vocab:group-membership-type')]",
)

@api.constrains("new_role_id")
def _check_single_head(self):
"""A group can have at most one Head of Household."""
head = self.env["spp.vocabulary.code"].get_code(ROLE_NAMESPACE, HEAD_ROLE_CODE)
for detail in self.mapped("detail_id"):
if head and len(detail.member_line_ids.filtered(lambda r: r.new_role_id == head)) > 1:
raise ValidationError(_("A group can have at most one Head of Household."))
Loading
Loading