Skip to content

Commit 421330d

Browse files
committed
Add vote weight breakdown to tooltip
1 parent 5ddbf24 commit 421330d

File tree

8 files changed

+398
-14
lines changed

8 files changed

+398
-14
lines changed

astra_app/core/elections_eligibility.py

Lines changed: 144 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
5261
def _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+
5980
def _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

266397
def election_committee_disqualification(

astra_app/core/static/core/css/base.css

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,30 @@ a:hover {
166166
border-color: var(--alx-blue);
167167
}
168168

169+
/* Bootstrap tooltips default to ~200px max-width.
170+
The vote weight breakdown needs more room to avoid awkward wrapping. */
171+
.vote-breakdown-tooltip .tooltip-inner {
172+
max-width: 42rem;
173+
font-variant-numeric: tabular-nums;
174+
}
175+
176+
.vote-breakdown-tooltip .vote-breakdown-line,
177+
.vote-breakdown-tooltip .vote-breakdown-total {
178+
display: grid;
179+
grid-template-columns: 5ch 1ch 1fr;
180+
column-gap: 0.35rem;
181+
align-items: baseline;
182+
}
183+
184+
.vote-breakdown-tooltip .vote-breakdown-votes {
185+
text-align: right;
186+
white-space: nowrap;
187+
}
188+
189+
.vote-breakdown-tooltip .vote-breakdown-sep {
190+
text-align: center;
191+
}
192+
169193

170194
.auth-nav-search {
171195
flex: 1 1 auto;

astra_app/core/static/core/js/election_vote.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,4 +388,20 @@
388388

389389
setSubmitLabel('Submit vote');
390390
});
391+
392+
// Initialize vote breakdown tooltip with html:true.
393+
// We don't use data-toggle="tooltip" on this element to prevent AdminLTE's
394+
// global init (which uses html:false) from capturing it first.
395+
var breakdownBtn = document.getElementById('vote-breakdown-tooltip');
396+
if (breakdownBtn) {
397+
var breakdownContent = document.getElementById('vote-breakdown-tooltip-content');
398+
window.jQuery(breakdownBtn).tooltip({
399+
html: true,
400+
placement: breakdownBtn.getAttribute('data-placement') || 'right',
401+
title: function () {
402+
return breakdownContent ? breakdownContent.innerHTML : '';
403+
},
404+
template: '<div class="tooltip vote-breakdown-tooltip" role="tooltip"><div class="arrow"></div><div class="tooltip-inner text-left"></div></div>'
405+
});
406+
}
391407
})(window, document);

astra_app/core/static/core/vendor/popper/popper.min.js

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

astra_app/core/templates/core/base.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,7 @@
379379

380380
{% block scripts %}
381381
<script src="{% static 'admin/js/vendor/jquery/jquery.js' %}"></script>
382+
<script src="{% static 'core/vendor/popper/popper.min.js' %}"></script>
382383
<script src="{% static 'vendor/bootstrap/js/bootstrap.min.js' %}"></script>
383384
<script src="{% static 'vendor/adminlte/js/adminlte.min.js' %}"></script>
384385
<script src="{% static 'core/js/character_count.js' %}"></script>

astra_app/core/templates/core/election_vote.html

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,40 @@ <h3 class="card-title">Ballot</h3>
3636
{% if voter_votes > 0 %}
3737
<p class="form-text mb-0">
3838
You have <strong>{{ voter_votes }}</strong> vote{{ voter_votes|pluralize }} for this election.
39+
{% if voter_votes > 1 and voter_vote_breakdown %}
40+
<button
41+
type="button"
42+
id="vote-breakdown-tooltip"
43+
class="btn btn-link p-0 align-baseline border-0"
44+
style="font-size: inherit; vertical-align: baseline;"
45+
data-placement="right"
46+
aria-label="How your vote count is computed"
47+
>
48+
<i class="fas fa-info-circle text-muted" aria-hidden="true"></i>
49+
</button>
50+
{% endif %}
3951
</p>
52+
{% if voter_votes > 1 and voter_vote_breakdown %}
53+
<div id="vote-breakdown-tooltip-content" class="d-none">
54+
<div class="vote-breakdown-lines">
55+
{% for row in voter_vote_breakdown %}
56+
<div class="vote-breakdown-line">
57+
<span class="vote-breakdown-votes">{% if not forloop.first %}+ {% endif %}{{ row.votes }}</span>
58+
<span class="vote-breakdown-sep">:</span>
59+
<span class="vote-breakdown-text">{% if row.org_name %}Representative of {% endif %}{{ row.label }} member{% if row.org_name %} ({{ row.org_name }}){% endif %}</span>
60+
</div>
61+
{% endfor %}
62+
63+
<div class="border-top my-1"></div>
64+
65+
<div class="vote-breakdown-total">
66+
<span class="vote-breakdown-votes">{{ voter_votes }}</span>
67+
<span class="vote-breakdown-sep">:</span>
68+
<span class="vote-breakdown-text">Total Vote{{ voter_votes|pluralize }}</span>
69+
</div>
70+
</div>
71+
</div>
72+
{% endif %}
4073
{% if voter_votes > 1 %}
4174
<small class="text-muted">
4275
These votes are applied as extra weight to your single ranked ballot.

0 commit comments

Comments
 (0)