1- from odoo import api , fields , models
1+ # Part of OpenSPP. See LICENSE file for full copyright and licensing details.
2+ """Detail model for the Add Member CR (OP#871).
3+
4+ The first page searches for an existing individual registrant and adds them to
5+ the group with a role. (The earlier create-a-new-individual flow was replaced
6+ per the updated #871 spec — Add Member now selects an existing member.)
7+ """
8+
9+ from odoo import _ , api , fields , models
10+ from odoo .exceptions import ValidationError
211
312
413class SPPCRDetailAddMember (models .Model ):
@@ -8,57 +17,151 @@ class SPPCRDetailAddMember(models.Model):
817 _description = "CR Detail: Add Group Member"
918 _inherit = ["spp.cr.detail.base" , "mail.thread" ]
1019
11- # ══════════════════════════════════════════════════════════════════════════
12- # MEMBER INFORMATION - Real Odoo fields with full features
13- # ══════════════════════════════════════════════════════════════════════════
20+ # ──────────────────────────────────────────────────────────────────────
21+ # Target group identity (helps distinguish same-named groups — OP#871)
22+ # ──────────────────────────────────────────────────────────────────────
23+ registrant_registration_date = fields .Date (
24+ related = "change_request_id.registrant_id.registration_date" ,
25+ string = "Group Registration Date" ,
26+ readonly = True ,
27+ )
28+ registrant_id_display = fields .Char (
29+ string = "Group ID(s)" ,
30+ compute = "_compute_registrant_id_display" ,
31+ help = "The target group's registry ID type(s) and value(s)." ,
32+ )
1433
15- member_name = fields .Char (
16- string = "Full Name" ,
34+ # ──────────────────────────────────────────────────────────────────────
35+ # Member to add (existing individual)
36+ # ──────────────────────────────────────────────────────────────────────
37+ individual_id = fields .Many2one (
38+ "res.partner" ,
39+ string = "Member" ,
1740 tracking = True ,
18- help = "Required before apply. Auto-computed from given/family names ." ,
41+ help = "Search for an existing individual registrant to add to the group ." ,
1942 )
20- given_name = fields .Char (string = "Given Name" , tracking = True )
21- family_name = fields .Char (string = "Family Name" , tracking = True )
22- birthdate = fields .Date (string = "Date of Birth" , tracking = True )
23- gender_id = fields .Many2one (
43+ # Domain string (computed) restricting the picker to existing individual
44+ # registrants who are not already active members of the group. A computed
45+ # domain is used instead of a materialised Many2many so the picker scales
46+ # with a large registry (mirrors spp.change.request.registrant_domain).
47+ individual_domain = fields .Char (compute = "_compute_individual_domain" )
48+
49+ # ──────────────────────────────────────────────────────────────────────
50+ # Role (per-member, single row)
51+ # ──────────────────────────────────────────────────────────────────────
52+ membership_type_id = fields .Many2one (
2453 "spp.vocabulary.code" ,
25- string = "Gender " ,
26- domain = "[('namespace_uri', '=', 'urn:iso:std:iso:5218 ')]" ,
54+ string = "Role " ,
55+ domain = "[('vocabulary_id. namespace_uri', '=', 'urn:openspp:vocab:group-membership-type ')]" ,
2756 tracking = True ,
57+ help = "Role of the new member in the group. Optionality controlled by the CR type." ,
2858 )
29- relationship_id = fields .Many2one (
30- "spp.vocabulary.code" ,
31- string = "Relationship to Head" ,
32- domain = "[('vocabulary_id.namespace_uri', '=', 'urn:openspp:vocab:group-membership-type'),"
33- " ('code', '!=', 'head')]" ,
34- tracking = True ,
59+
60+ # ──────────────────────────────────────────────────────────────────────
61+ # Type-config mirrors (for view conditionals)
62+ # ──────────────────────────────────────────────────────────────────────
63+ type_requires_head = fields .Boolean (
64+ related = "change_request_id.request_type_id.requires_head" ,
65+ string = "Requires Head" ,
66+ )
67+ roles_available = fields .Boolean (
68+ compute = "_compute_roles_available" ,
69+ string = "Has Membership Roles" ,
70+ help = "True when the urn:openspp:vocab:group-membership-type vocabulary has any active code." ,
3571 )
36- id_number = fields .Char (string = "ID Number" , tracking = True )
37- phone = fields .Char (string = "Phone Number" , tracking = True )
3872
39- # Reference to created individual (set after apply)
40- created_individual_id = fields .Many2one (
41- "res.partner" ,
42- string = "Created Individual" ,
43- readonly = True ,
73+ # ──────────────────────────────────────────────────────────────────────
74+ # Read-only context: existing members of the group
75+ # ──────────────────────────────────────────────────────────────────────
76+ existing_membership_ids = fields .Many2many (
77+ "spp.group.membership" ,
78+ string = "Existing Members" ,
79+ compute = "_compute_existing_memberships" ,
4480 )
4581
46- # ══════════════════════════════════════════════════════════════════════════
47- # ONCHANGE - Full Odoo functionality
48- # ══════════════════════════════════════════════════════════════════════════
49-
50- @api .onchange ("given_name" , "family_name" )
51- def _onchange_names (self ):
52- """Auto-compute full name from given + family."""
53- if self .given_name or self .family_name :
54- name_vals = [
55- f"{ self .family_name } ,"
56- if self .family_name and self .given_name
57- else f"{ self .family_name } "
58- if self .family_name
59- else "" ,
60- self .given_name ,
61- ]
62-
63- name = " " .join (filter (None , name_vals ))
64- self .member_name = name .upper ()
82+ # ──────────────────────────────────────────────────────────────────────
83+ # Computes
84+ # ──────────────────────────────────────────────────────────────────────
85+ def _active_member_individual_ids (self , group ):
86+ """ids of individuals who are active members of the group."""
87+ if not group or not group .is_group :
88+ return []
89+ memberships = self .env ["spp.group.membership" ].search ([("group" , "=" , group .id ), ("status" , "=" , "active" )])
90+ return memberships .mapped ("individual" ).ids
91+
92+ @api .depends ("change_request_id" , "change_request_id.registrant_id" )
93+ def _compute_individual_domain (self ):
94+ for rec in self :
95+ member_ids = rec ._active_member_individual_ids (rec .change_request_id .registrant_id )
96+ rec .individual_domain = str (
97+ [
98+ ("is_registrant" , "=" , True ),
99+ ("is_group" , "=" , False ),
100+ ("id" , "not in" , member_ids ),
101+ ]
102+ )
103+
104+ @api .depends_context ("uid" )
105+ def _compute_roles_available (self ):
106+ has_any = bool (
107+ self .env ["spp.vocabulary.code" ].search_count (
108+ [("vocabulary_id.namespace_uri" , "=" , "urn:openspp:vocab:group-membership-type" )]
109+ )
110+ )
111+ for rec in self :
112+ rec .roles_available = has_any
113+
114+ @api .depends ("change_request_id" , "change_request_id.registrant_id" )
115+ def _compute_existing_memberships (self ):
116+ Membership = self .env ["spp.group.membership" ]
117+ for rec in self :
118+ group = rec .change_request_id .registrant_id
119+ if group and group .is_group :
120+ rec .existing_membership_ids = Membership .search ([("group" , "=" , group .id ), ("status" , "=" , "active" )])
121+ else :
122+ rec .existing_membership_ids = Membership .browse ([])
123+
124+ @api .depends ("change_request_id.registrant_id.reg_ids" )
125+ def _compute_registrant_id_display (self ):
126+ """Render the target group's registry IDs as 'Type: value; ...' so a
127+ group can be told apart from others with the same name (OP#871)."""
128+ for rec in self :
129+ group = rec .change_request_id .registrant_id
130+ ids = group .reg_ids if group and "reg_ids" in group ._fields else False
131+ rec .registrant_id_display = (
132+ "; " .join (f"{ i .id_type_as_str or '' } : { i .value or '' } " .strip (": " ) for i in ids ) if ids else False
133+ )
134+
135+ @api .constrains ("membership_type_id" )
136+ def _check_single_head (self ):
137+ """A group can have at most one Head of Household. Reject choosing the
138+ Head role when the target group already has an active head — at save /
139+ submit time, not just on apply (OP#871, uniform with the other CRs)."""
140+ for rec in self :
141+ role = rec .membership_type_id
142+ group = rec .change_request_id .registrant_id
143+ if not role or role .code != "head" or not group or not group .is_group :
144+ continue
145+ group_has_head = self .env ["spp.group.membership" ].search_count (
146+ [
147+ ("group" , "=" , group .id ),
148+ ("status" , "=" , "active" ),
149+ ("membership_type_ids.code" , "=" , "head" ),
150+ ]
151+ )
152+ if group_has_head :
153+ raise ValidationError (_ ("This group already has a Head of Household. Only one member can be Head." ))
154+
155+
156+ class SPPGroupMembership (models .Model ):
157+ """Expose the member's registration date for the Add Member CR's
158+ Current Members table (OP#871) — membership start_date is the join date,
159+ not the individual's registration date, so they are shown separately."""
160+
161+ _inherit = "spp.group.membership"
162+
163+ individual_registration_date = fields .Date (
164+ related = "individual.registration_date" ,
165+ string = "Registration Date" ,
166+ readonly = True ,
167+ )
0 commit comments