Skip to content

Commit 58d6033

Browse files
committed
feat(change_request_v2): redesign Change Head of Household CR (#873)
Replace the single new-head dropdown with an editable members-with-roles table: one role line is seeded per active group member, and the member assigned the Head role becomes the new head (a constraint blocks two heads). Make the current head non-clickable, drop the unused Effective Date field, and remove the Voluntary Transfer reason. Add reason-driven required documents: a per-reason document config on the CR type (shown only for types whose detail exposes a reason) drives the request's required documents via _get_effective_required_document_ids(). The review preview now renders the household, reason, remarks and a members table (Name / Current Role / New Role).
1 parent d0d65b7 commit 58d6033

9 files changed

Lines changed: 455 additions & 356 deletions

File tree

Lines changed: 76 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
1-
from odoo import api, fields, models
1+
from odoo import _, api, fields, models
2+
from odoo.exceptions import ValidationError
3+
4+
ROLE_NAMESPACE = "urn:openspp:vocab:group-membership-type"
5+
HEAD_ROLE_CODE = "head"
26

37

48
class SPPCRDetailChangeHOH(models.Model):
5-
"""Detail model for Change Head of Household CR type."""
9+
"""Detail model for Change Head of Household CR type (OP#873)."""
610

711
_name = "spp.cr.detail.change_hoh"
812
_description = "CR Detail: Change Head of Household"
@@ -19,51 +23,26 @@ class SPPCRDetailChangeHOH(models.Model):
1923
store=True,
2024
readonly=True,
2125
)
22-
available_individual_ids = fields.Many2many(
23-
"res.partner",
24-
string="Available Individuals",
25-
compute="_compute_available_individuals",
26-
help="Active members excluding current head of household",
27-
)
28-
new_head_id = fields.Many2one(
29-
"res.partner",
30-
string="New Head of Household",
31-
tracking=True,
32-
domain="[('is_group', '=', False), ('is_registrant', '=', True)]",
33-
help="Select the individual who will become the new head of household",
34-
)
35-
new_head_membership_id = fields.Many2one(
36-
"spp.group.membership",
37-
string="New Head Membership",
38-
readonly=True,
39-
help="Membership record for the new head (automatically set)",
40-
)
41-
previous_head_new_role_id = fields.Many2one(
42-
"spp.vocabulary.code",
43-
string="Previous Head's New Role",
44-
domain="[('vocabulary_id.namespace_uri', '=', 'urn:openspp:vocab:group-membership-type'),"
45-
" ('code', '!=', 'head')]",
46-
tracking=True,
47-
help="The new role for the previous head (e.g., Spouse, Other Adult)",
26+
# One editable role line per active group member. The new head is whichever
27+
# member is assigned the Head role (OP#873 — replaces the single new-head
28+
# dropdown with a members-with-roles table).
29+
member_line_ids = fields.One2many(
30+
"spp.cr.detail.change_hoh.member",
31+
"detail_id",
32+
string="Members",
4833
)
4934
reason = fields.Selection(
5035
[
5136
("deceased", "Head Deceased"),
5237
("incapacitated", "Head Incapacitated"),
5338
("left_household", "Head Left Household"),
5439
("age_change", "Age-based Change"),
55-
("voluntary", "Voluntary Transfer"),
5640
("correction", "Data Correction"),
5741
("other", "Other"),
5842
],
5943
string="Reason for Change",
6044
tracking=True,
6145
)
62-
effective_date = fields.Date(
63-
string="Effective Date",
64-
default=fields.Date.today,
65-
tracking=True,
66-
)
6746
remarks = fields.Text(string="Remarks", tracking=True)
6847

6948
# ══════════════════════════════════════════════════════════════════════════
@@ -73,58 +52,80 @@ class SPPCRDetailChangeHOH(models.Model):
7352
@api.depends("change_request_id.registrant_id")
7453
def _compute_current_head(self):
7554
"""Find the current head of household."""
76-
head_kind = self.env["spp.vocabulary.code"].get_code("urn:openspp:vocab:group-membership-type", "head")
55+
head_kind = self.env["spp.vocabulary.code"].get_code(ROLE_NAMESPACE, HEAD_ROLE_CODE)
7756
for rec in self:
7857
current_head = False
7958
if rec.change_request_id.registrant_id and head_kind:
80-
memberships = self.env["spp.group.membership"].search(
59+
membership = self.env["spp.group.membership"].search(
8160
[
8261
("group", "=", rec.change_request_id.registrant_id.id),
8362
("membership_type_ids", "in", [head_kind.id]),
8463
("status", "=", "active"),
8564
],
8665
limit=1,
8766
)
88-
if memberships:
89-
current_head = memberships.individual
67+
if membership:
68+
current_head = membership.individual
9069
rec.current_head_id = current_head
9170

92-
@api.depends("change_request_id.registrant_id", "current_head_id")
93-
def _compute_available_individuals(self):
94-
"""Compute available individuals excluding current head of household."""
95-
head_kind = self.env["spp.vocabulary.code"].get_code("urn:openspp:vocab:group-membership-type", "head")
96-
for rec in self:
97-
available_individuals = self.env["res.partner"]
98-
if rec.change_request_id.registrant_id:
99-
# Get all active memberships for this group
100-
all_memberships = self.env["spp.group.membership"].search(
101-
[
102-
("group", "=", rec.change_request_id.registrant_id.id),
103-
("status", "=", "active"),
104-
]
105-
)
106-
# Exclude current head
107-
if head_kind:
108-
available_memberships = all_memberships.filtered(lambda m: head_kind not in m.membership_type_ids)
109-
else:
110-
available_memberships = all_memberships
111-
# Get the individuals from these memberships
112-
available_individuals = available_memberships.mapped("individual")
113-
rec.available_individual_ids = available_individuals
71+
@api.model_create_multi
72+
def create(self, vals_list):
73+
details = super().create(vals_list)
74+
for detail in details:
75+
if not detail.member_line_ids:
76+
detail._seed_member_lines()
77+
return details
11478

115-
@api.onchange("new_head_id")
116-
def _onchange_new_head_id(self):
117-
"""Set the membership_id when individual is selected."""
118-
self.new_head_membership_id = False
119-
if self.new_head_id and self.change_request_id.registrant_id:
120-
# Find the membership for this individual in this group
121-
membership = self.env["spp.group.membership"].search(
122-
[
123-
("group", "=", self.change_request_id.registrant_id.id),
124-
("individual", "=", self.new_head_id.id),
125-
("status", "=", "active"),
126-
],
127-
limit=1,
79+
def _seed_member_lines(self):
80+
"""Populate one editable role line per active group member, defaulting
81+
each member's new role to their current role."""
82+
self.ensure_one()
83+
group = self.change_request_id.registrant_id
84+
if not group or not group.is_group:
85+
return
86+
memberships = self.env["spp.group.membership"].search([("group", "=", group.id), ("status", "=", "active")])
87+
lines = []
88+
for membership in memberships:
89+
current_roles = membership.membership_type_ids
90+
lines.append(
91+
(
92+
0,
93+
0,
94+
{
95+
"individual_id": membership.individual.id,
96+
"membership_id": membership.id,
97+
"old_role_display": ", ".join(current_roles.mapped("display")),
98+
"new_role_id": current_roles[:1].id if current_roles else False,
99+
},
100+
)
128101
)
129-
if membership:
130-
self.new_head_membership_id = membership
102+
self.member_line_ids = lines
103+
104+
105+
class SPPCRDetailChangeHOHMember(models.Model):
106+
"""One editable role line per current group member (Change HoH, OP#873)."""
107+
108+
_name = "spp.cr.detail.change_hoh.member"
109+
_description = "CR Detail: Change HoH - Member Role"
110+
111+
detail_id = fields.Many2one(
112+
"spp.cr.detail.change_hoh",
113+
required=True,
114+
ondelete="cascade",
115+
)
116+
individual_id = fields.Many2one("res.partner", string="Member", readonly=True)
117+
membership_id = fields.Many2one("spp.group.membership", readonly=True)
118+
old_role_display = fields.Char(string="Current Role", readonly=True)
119+
new_role_id = fields.Many2one(
120+
"spp.vocabulary.code",
121+
string="New Role",
122+
domain="[('vocabulary_id.namespace_uri', '=', 'urn:openspp:vocab:group-membership-type')]",
123+
)
124+
125+
@api.constrains("new_role_id")
126+
def _check_single_head(self):
127+
"""A group can have at most one Head of Household."""
128+
head = self.env["spp.vocabulary.code"].get_code(ROLE_NAMESPACE, HEAD_ROLE_CODE)
129+
for detail in self.mapped("detail_id"):
130+
if head and len(detail.member_line_ids.filtered(lambda r: r.new_role_id == head)) > 1:
131+
raise ValidationError(_("A group can have at most one Head of Household."))

spp_change_request_v2/models/change_request.py

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -392,10 +392,36 @@ def _compute_stage_banner_html(self):
392392
)
393393
rec.stage_banner_html = html
394394

395-
@api.depends("document_ids", "document_ids.document_type_id", "request_type_id.required_document_ids")
395+
def _get_effective_required_document_ids(self):
396+
"""Return the document types required for this request.
397+
398+
When the request type defines per-reason document rules (OP#873) and the
399+
request's detail exposes a matching reason, that rule's documents take
400+
precedence over the flat ``required_document_ids`` list. A configured
401+
rule with no documents means nothing is required for that reason."""
402+
self.ensure_one()
403+
empty = self.env["spp.vocabulary.code"]
404+
rt = self.request_type_id
405+
if not rt:
406+
return empty
407+
reason_rules = rt.reason_document_ids
408+
if reason_rules:
409+
detail = self.get_detail()
410+
reason = detail.reason if detail and "reason" in detail._fields else False
411+
if reason:
412+
rule = reason_rules.filtered(lambda r: r.reason == reason)
413+
return rule[:1].required_document_ids if rule else empty
414+
return rt.required_document_ids
415+
416+
@api.depends(
417+
"document_ids",
418+
"document_ids.document_type_id",
419+
"request_type_id.required_document_ids",
420+
"request_type_id.reason_document_ids",
421+
)
396422
def _compute_missing_required_documents(self):
397423
for rec in self:
398-
required = rec.request_type_id.required_document_ids if rec.request_type_id else None
424+
required = rec._get_effective_required_document_ids() if rec.request_type_id else None
399425
if not required:
400426
rec.missing_required_document_ids = self.env["spp.vocabulary.code"]
401427
rec.documents_complete = True
@@ -405,10 +431,15 @@ def _compute_missing_required_documents(self):
405431
rec.missing_required_document_ids = missing
406432
rec.documents_complete = not bool(missing)
407433

408-
@api.depends("document_ids", "document_ids.document_type_id", "request_type_id.required_document_ids")
434+
@api.depends(
435+
"document_ids",
436+
"document_ids.document_type_id",
437+
"request_type_id.required_document_ids",
438+
"request_type_id.reason_document_ids",
439+
)
409440
def _compute_required_documents_html(self):
410441
for rec in self:
411-
required = rec.request_type_id.required_document_ids if rec.request_type_id else None
442+
required = rec._get_effective_required_document_ids() if rec.request_type_id else None
412443
if not required:
413444
rec.required_documents_html = (
414445
'<div class="alert alert-info mb-3 py-2">'

spp_change_request_v2/models/change_request_type.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,23 @@ class SPPChangeRequestType(models.Model):
171171
string="Required Documents (Deprecated)",
172172
help="Deprecated: Use required_document_ids instead",
173173
)
174+
# Per-reason document rules (OP#873). When a CR type exposes a "reason"
175+
# (e.g. Change Head of Household), the required documents can be driven by
176+
# the chosen reason instead of the flat required_document_ids list.
177+
supports_reason_documents = fields.Boolean(
178+
string="Supports Reason-based Documents",
179+
compute="_compute_supports_reason_documents",
180+
help="True when the detail model exposes a 'reason' field, so required "
181+
"documents can depend on the chosen reason.",
182+
)
183+
reason_document_ids = fields.One2many(
184+
"spp.cr.type.reason.document",
185+
"cr_type_id",
186+
string="Required Documents by Reason",
187+
help="Optional: make required documents depend on the request's Reason. "
188+
"When set and the request has a matching reason, these documents are "
189+
"required in place of the flat 'Required Documents' list.",
190+
)
174191
allow_document_download = fields.Boolean(
175192
string="Allow Document Download",
176193
default=False,
@@ -343,6 +360,15 @@ def _compute_detail_model_exists(self):
343360
for rec in self:
344361
rec.is_detail_model_exists = bool(rec.detail_model and rec.detail_model in self.env)
345362

363+
@api.depends("detail_model")
364+
def _compute_supports_reason_documents(self):
365+
"""A CR type supports reason-driven documents only when its detail model
366+
has a 'reason' field (e.g. Change Head of Household)."""
367+
for rec in self:
368+
rec.supports_reason_documents = bool(
369+
rec.detail_model and rec.detail_model in self.env and "reason" in self.env[rec.detail_model]._fields
370+
)
371+
346372
@api.depends("detail_model")
347373
def _compute_available_field_ids(self):
348374
"""Compute available fields from the detail model."""
@@ -521,3 +547,51 @@ def validate_required_fields(self, detail_record):
521547
missing_fields.append(field.field_description)
522548

523549
return len(missing_fields) == 0, missing_fields
550+
551+
552+
class SPPCRTypeReasonDocument(models.Model):
553+
"""Maps a request reason to the set of documents required for it (OP#873).
554+
555+
Configured on a change request type; consumed by
556+
spp.change.request._get_effective_required_document_ids(). The reason values
557+
mirror spp.cr.detail.change_hoh.reason."""
558+
559+
_name = "spp.cr.type.reason.document"
560+
_description = "CR Type: Required Documents by Reason"
561+
_order = "cr_type_id, reason"
562+
563+
cr_type_id = fields.Many2one(
564+
"spp.change.request.type",
565+
string="Change Request Type",
566+
required=True,
567+
ondelete="cascade",
568+
)
569+
reason = fields.Selection(
570+
[
571+
("deceased", "Head Deceased"),
572+
("incapacitated", "Head Incapacitated"),
573+
("left_household", "Head Left Household"),
574+
("age_change", "Age-based Change"),
575+
("correction", "Data Correction"),
576+
("other", "Other"),
577+
],
578+
string="Reason",
579+
required=True,
580+
)
581+
required_document_ids = fields.Many2many(
582+
"spp.vocabulary.code",
583+
"cr_type_reason_doc_rel",
584+
"rule_id",
585+
"doc_id",
586+
string="Required Documents",
587+
domain="[('vocabulary_id.namespace_uri', '=', 'urn:openspp:vocab:cr_document_type')]",
588+
help="Documents required when the request's reason matches this rule.",
589+
)
590+
591+
_sql_constraints = [
592+
(
593+
"reason_uniq",
594+
"unique(cr_type_id, reason)",
595+
"A reason can only have one document rule per change request type.",
596+
),
597+
]

spp_change_request_v2/security/ir.model.access.csv

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,3 +151,11 @@ access_spp_change_request_log_user,spp.change.request.log user,model_spp_change_
151151
access_spp_change_request_log_validator,spp.change.request.log validator,model_spp_change_request_log,group_cr_validator,1,0,0,0
152152
access_spp_change_request_log_validator_hq,spp.change.request.log validator hq,model_spp_change_request_log,group_cr_validator_hq,1,0,0,0
153153
access_spp_change_request_log_manager,spp.change.request.log manager,model_spp_change_request_log,group_cr_manager,1,1,1,1
154+
access_spp_cr_detail_change_hoh_member_user,spp.cr.detail.change_hoh.member user,model_spp_cr_detail_change_hoh_member,group_cr_user,1,1,1,1
155+
access_spp_cr_detail_change_hoh_member_validator,spp.cr.detail.change_hoh.member validator,model_spp_cr_detail_change_hoh_member,group_cr_validator,1,1,1,1
156+
access_spp_cr_detail_change_hoh_member_validator_hq,spp.cr.detail.change_hoh.member validator hq,model_spp_cr_detail_change_hoh_member,group_cr_validator_hq,1,1,1,1
157+
access_spp_cr_detail_change_hoh_member_manager,spp.cr.detail.change_hoh.member manager,model_spp_cr_detail_change_hoh_member,group_cr_manager,1,1,1,1
158+
access_spp_cr_type_reason_document_user,spp.cr.type.reason.document user,model_spp_cr_type_reason_document,group_cr_user,1,0,0,0
159+
access_spp_cr_type_reason_document_validator,spp.cr.type.reason.document validator,model_spp_cr_type_reason_document,group_cr_validator,1,0,0,0
160+
access_spp_cr_type_reason_document_validator_hq,spp.cr.type.reason.document validator hq,model_spp_cr_type_reason_document,group_cr_validator_hq,1,0,0,0
161+
access_spp_cr_type_reason_document_manager,spp.cr.type.reason.document manager,model_spp_cr_type_reason_document,group_cr_manager,1,1,1,1

0 commit comments

Comments
 (0)