@@ -49,13 +49,34 @@ class EligibilityFacts:
4949 has_active_vote_eligible_at_reference : bool
5050
5151
52+ @dataclass (frozen = True )
53+ class VoteWeightLine :
54+ """A single membership contributing to a voter's weight."""
55+
56+ label : str
57+ org_name : str
58+ votes : int
59+
60+
5261def _election_reference_datetime (* , election : Election ) -> datetime .datetime :
5362 reference_datetime = election .start_datetime
5463 if election .status == Election .Status .draft :
5564 reference_datetime = max (election .start_datetime , timezone .now ())
5665 return reference_datetime
5766
5867
68+ def _membership_is_active_at_reference (
69+ * ,
70+ expires_at : datetime .datetime | None ,
71+ reference_datetime : datetime .datetime ,
72+ ) -> bool :
73+ return expires_at is None or expires_at >= reference_datetime
74+
75+
76+ def _membership_is_old_enough (* , created_at : datetime .datetime | None , cutoff : datetime .datetime ) -> bool :
77+ return created_at is not None and created_at <= cutoff
78+
79+
5980def _eligibility_facts_by_username (* , election : Election ) -> dict [str , EligibilityFacts ]:
6081 reference_datetime = _election_reference_datetime (election = election )
6182 cutoff = reference_datetime - datetime .timedelta (days = settings .ELECTION_ELIGIBILITY_MIN_MEMBERSHIP_AGE_DAYS )
@@ -87,13 +108,17 @@ def _eligibility_facts_by_username(*, election: Election) -> dict[str, Eligibili
87108 term_start_by_username [username ] = start_at
88109
89110 expires_at = row .get ("expires_at" )
90- is_active_at_reference = expires_at is None or (
91- isinstance (expires_at , datetime .datetime ) and expires_at >= reference_datetime
92- )
111+ is_active_at_reference = False
112+ if expires_at is None or isinstance (expires_at , datetime .datetime ):
113+ is_active_at_reference = _membership_is_active_at_reference (
114+ expires_at = expires_at ,
115+ reference_datetime = reference_datetime ,
116+ )
93117 if is_active_at_reference :
94118 has_active_vote_eligible_at_reference .add (username )
95119
96- if is_active_at_reference and isinstance (start_at , datetime .datetime ) and start_at <= cutoff :
120+ created_at = start_at if isinstance (start_at , datetime .datetime ) else None
121+ if is_active_at_reference and _membership_is_old_enough (created_at = created_at , cutoff = cutoff ):
97122 weights_by_username [username ] = weights_by_username .get (username , 0 ) + votes
98123
99124 org_memberships = (
@@ -126,12 +151,14 @@ def _eligibility_facts_by_username(*, election: Election) -> dict[str, Eligibili
126151 if username not in term_start_by_username or start_at < term_start_by_username [username ]:
127152 term_start_by_username [username ] = start_at
128153
129- expires_at = membership .expires_at
130- is_active_at_reference = expires_at is None or expires_at >= reference_datetime
154+ is_active_at_reference = _membership_is_active_at_reference (
155+ expires_at = membership .expires_at ,
156+ reference_datetime = reference_datetime ,
157+ )
131158 if is_active_at_reference :
132159 has_active_vote_eligible_at_reference .add (username )
133160
134- if is_active_at_reference and start_at <= cutoff :
161+ if is_active_at_reference and _membership_is_old_enough ( created_at = start_at , cutoff = cutoff ) :
135162 weights_by_username [username ] = weights_by_username .get (username , 0 ) + votes
136163
137164 usernames = set (weights_by_username ) | set (term_start_by_username ) | has_any_vote_eligible | has_active_vote_eligible_at_reference
@@ -255,12 +282,116 @@ def eligible_vote_weight_for_username(*, election: Election, username: str) -> i
255282 if not eligible_usernames or username .lower () not in eligible_usernames :
256283 return 0
257284
258- # Reuse the canonical eligibility computation and extract the single user's weight.
259- voters = _eligible_voters_from_memberships (election = election )
260- for voter in voters :
261- if voter .username .lower () == username .lower ():
262- return voter .weight
263- return 0
285+ return sum (
286+ line .votes
287+ for line in vote_weight_breakdown_for_username (
288+ election = election ,
289+ username = username ,
290+ )
291+ )
292+
293+
294+ def vote_weight_breakdown_for_username (* , election : Election , username : str ) -> list [VoteWeightLine ]:
295+ """Return per-membership vote weight breakdown for the given username.
296+
297+ Applies the same eligibility criteria as canonical vote weight computation:
298+ membership must be active at the election reference datetime and at least
299+ ELECTION_ELIGIBILITY_MIN_MEMBERSHIP_AGE_DAYS old.
300+
301+ Returns individual memberships before organization memberships and includes
302+ only memberships that contribute votes.
303+ """
304+ username_lower = str (username or "" ).strip ().lower ()
305+ if not username_lower :
306+ return []
307+
308+ reference_datetime = _election_reference_datetime (election = election )
309+ cutoff = reference_datetime - datetime .timedelta (days = settings .ELECTION_ELIGIBILITY_MIN_MEMBERSHIP_AGE_DAYS )
310+
311+ lines : list [VoteWeightLine ] = []
312+
313+ individual_memberships = (
314+ Membership .objects .select_related ("membership_type" )
315+ .filter (
316+ target_username__iexact = username_lower ,
317+ membership_type__category__is_individual = True ,
318+ membership_type__enabled = True ,
319+ membership_type__votes__gt = 0 ,
320+ )
321+ .order_by (
322+ "membership_type__category__sort_order" ,
323+ "membership_type__sort_order" ,
324+ "membership_type__code" ,
325+ "pk" ,
326+ )
327+ .only (
328+ "created_at" ,
329+ "expires_at" ,
330+ "membership_type__name" ,
331+ "membership_type__votes" ,
332+ )
333+ )
334+ for membership in individual_memberships :
335+ votes = int (membership .membership_type .votes or 0 )
336+ if votes <= 0 :
337+ continue
338+
339+ is_active = _membership_is_active_at_reference (
340+ expires_at = membership .expires_at ,
341+ reference_datetime = reference_datetime ,
342+ )
343+ if not is_active :
344+ continue
345+
346+ if not _membership_is_old_enough (created_at = membership .created_at , cutoff = cutoff ):
347+ continue
348+
349+ lines .append (VoteWeightLine (label = membership .membership_type .name , org_name = "" , votes = votes ))
350+
351+ org_memberships = (
352+ Membership .objects .select_related ("target_organization" , "membership_type" )
353+ .filter (
354+ target_organization__isnull = False ,
355+ target_organization__representative__iexact = username_lower ,
356+ membership_type__enabled = True ,
357+ membership_type__votes__gt = 0 ,
358+ )
359+ .order_by (
360+ "membership_type__category__sort_order" ,
361+ "membership_type__sort_order" ,
362+ "membership_type__code" ,
363+ "pk" ,
364+ )
365+ .only (
366+ "target_organization__name" ,
367+ "created_at" ,
368+ "expires_at" ,
369+ "membership_type__name" ,
370+ "membership_type__votes" ,
371+ )
372+ )
373+ for membership in org_memberships :
374+ if membership .target_organization is None :
375+ continue
376+
377+ votes = int (membership .membership_type .votes or 0 )
378+ if votes <= 0 :
379+ continue
380+
381+ is_active = _membership_is_active_at_reference (
382+ expires_at = membership .expires_at ,
383+ reference_datetime = reference_datetime ,
384+ )
385+ if not is_active :
386+ continue
387+
388+ if not _membership_is_old_enough (created_at = membership .created_at , cutoff = cutoff ):
389+ continue
390+
391+ org_name = str (membership .target_organization .name or "" ).strip ()
392+ lines .append (VoteWeightLine (label = membership .membership_type .name , org_name = org_name , votes = votes ))
393+
394+ return lines
264395
265396
266397def election_committee_disqualification (
0 commit comments