Skip to content

Commit e34c7a8

Browse files
authored
Add standard deviation to grants review recap for vote divergence (#4532)
1 parent d026a87 commit e34c7a8

File tree

3 files changed

+281
-1
lines changed

3 files changed

+281
-1
lines changed

backend/reviews/admin.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
OuterRef,
1414
Prefetch,
1515
Q,
16+
StdDev,
1617
Subquery,
1718
Sum,
1819
)
@@ -426,6 +427,10 @@ def _review_grants_recap_view(self, request, review_session):
426427
F("total_score") / F("vote_count"),
427428
output_field=FloatField(),
428429
),
430+
std_dev=StdDev(
431+
"userreview__score__numeric_value",
432+
filter=Q(userreview__review_session_id=review_session_id),
433+
),
429434
has_sent_a_proposal=Exists(
430435
Submission.objects.non_cancelled().filter(
431436
speaker_id=OuterRef("user_id"),

backend/reviews/templates/grants-recap.html

Lines changed: 158 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,74 @@
181181
tr:nth-of-type(odd) {
182182
background-color: var(--body-bg);
183183
}
184+
185+
/* Tooltip */
186+
.tooltip {
187+
position: relative;
188+
display: inline-block;
189+
}
190+
191+
.tooltip .tooltiptext {
192+
visibility: hidden;
193+
opacity: 0;
194+
width: 220px;
195+
background-color: #333;
196+
color: #fff;
197+
text-align: left;
198+
text-transform: none;
199+
padding: 8px 10px;
200+
border-radius: 6px;
201+
position: absolute;
202+
z-index: 1;
203+
top: 125%;
204+
left: 50%;
205+
margin-left: -110px;
206+
font-size: 12px;
207+
font-weight: normal;
208+
line-height: 1.4;
209+
transition: opacity 0.2s ease-in-out 0.5s, visibility 0.2s ease-in-out 0.5s;
210+
}
211+
212+
.tooltip .tooltiptext::after {
213+
content: "";
214+
position: absolute;
215+
bottom: 100%;
216+
left: 50%;
217+
margin-left: -5px;
218+
border-width: 5px;
219+
border-style: solid;
220+
border-color: transparent transparent #333 transparent;
221+
}
222+
223+
.tooltip:hover .tooltiptext {
224+
visibility: visible;
225+
opacity: 1;
226+
}
227+
228+
/* Sortable columns */
229+
.sortable {
230+
cursor: pointer;
231+
user-select: none;
232+
}
233+
234+
.sortable:hover {
235+
text-decoration: underline;
236+
}
237+
238+
/* Center-align columns */
239+
.results-table th:nth-child(1), /* number column */
240+
.results-table th:nth-child(3), /* score column */
241+
.results-table th:nth-child(4), /* std dev column */
242+
.results-table th:nth-child(6), /* current status column */
243+
.results-table th:nth-child(7), /* pending status column */
244+
.results-table td:nth-child(1), /* number column */
245+
.results-table td:nth-child(3), /* score column */
246+
.results-table td:nth-child(4), /* std dev column */
247+
.results-table td:nth-child(6), /* current status column */
248+
.results-table td:nth-child(7) /* pending status column */
249+
{
250+
text-align: center;
251+
}
184252
</style>
185253
<script type="application/javascript">
186254
const grantsById = {};
@@ -372,6 +440,84 @@
372440
{% endfor %}
373441

374442
};
443+
444+
// Sorting functionality
445+
const sortTable = (sortBy, direction) => {
446+
const tbody = document.querySelector('#result_list tbody');
447+
const rows = Array.from(tbody.querySelectorAll('tr.grant-item'));
448+
449+
rows.sort((a, b) => {
450+
let aVal, bVal;
451+
452+
if (sortBy === 'score') {
453+
aVal = parseFloat(a.dataset.score);
454+
bVal = parseFloat(b.dataset.score);
455+
} else if (sortBy === 'std_dev') {
456+
aVal = parseFloat(a.dataset.stdDev);
457+
bVal = parseFloat(b.dataset.stdDev);
458+
}
459+
460+
// Handle null/NaN values - push them to the end
461+
if (isNaN(aVal)) aVal = direction === 'desc' ? -Infinity : Infinity;
462+
if (isNaN(bVal)) bVal = direction === 'desc' ? -Infinity : Infinity;
463+
464+
if (direction === 'desc') {
465+
return bVal - aVal;
466+
} else {
467+
return aVal - bVal;
468+
}
469+
});
470+
471+
// Re-append rows in sorted order and update row numbers
472+
rows.forEach((row, index) => {
473+
row.querySelector('td:first-child').textContent = index + 1;
474+
tbody.appendChild(row);
475+
});
476+
};
477+
478+
const initSorting = () => {
479+
document.querySelectorAll('.sortable').forEach(header => {
480+
header.addEventListener('click', (e) => {
481+
e.stopPropagation();
482+
const sortBy = header.dataset.sort;
483+
let currentDir = header.dataset.sortDir;
484+
485+
// Toggle direction
486+
let newDir;
487+
if (currentDir === 'desc') {
488+
newDir = 'asc';
489+
} else {
490+
newDir = 'desc';
491+
}
492+
493+
// Update all sortable headers
494+
document.querySelectorAll('.sortable').forEach(h => {
495+
h.dataset.sortDir = '';
496+
// Remove arrow from text, keeping tooltip if present
497+
const tooltipSpan = h.querySelector('.tooltiptext');
498+
if (tooltipSpan) {
499+
h.childNodes[0].textContent = h.childNodes[0].textContent.replace(/ []/, '');
500+
} else {
501+
h.textContent = h.textContent.replace(/ []/, '');
502+
}
503+
});
504+
505+
// Set new direction and arrow
506+
header.dataset.sortDir = newDir;
507+
const arrow = newDir === 'desc' ? ' ▼' : ' ▲';
508+
const tooltipSpan = header.querySelector('.tooltiptext');
509+
if (tooltipSpan) {
510+
header.childNodes[0].textContent = header.childNodes[0].textContent.trim() + arrow;
511+
} else {
512+
header.textContent = header.textContent.trim() + arrow;
513+
}
514+
515+
sortTable(sortBy, newDir);
516+
});
517+
});
518+
};
519+
520+
window.addEventListener('load', initSorting);
375521
</script>
376522
<ul class="object-tools">
377523
<li>
@@ -428,7 +574,15 @@ <h3>
428574
</th>
429575
<th scope="col">
430576
<div class="text">
431-
<a>Score</a>
577+
<span class="sortable" data-sort="score" data-sort-dir="desc">Score ▼</span>
578+
</div>
579+
<div class="clear"></div>
580+
</th>
581+
<th scope="col">
582+
<div class="text">
583+
<span class="tooltip sortable" data-sort="std_dev" data-sort-dir="">Std Dev
584+
<span class="tooltiptext">Standard Deviation: measures reviewer disagreement. High value = controversial (needs discussion), Low value = consensus.</span>
585+
</span>
432586
</div>
433587
<div class="clear"></div>
434588
</th>
@@ -481,6 +635,8 @@ <h3>
481635
id="grant-{{ item.id }}"
482636
data-type="{{ item.type }}"
483637
data-num-of-votes="{{ item.userreview_set.count }}"
638+
data-score="{{ item.score|default_if_none:'-999' }}"
639+
data-std-dev="{{ item.std_dev|default_if_none:'-1' }}"
484640
>
485641
<td>{{ forloop.counter }}</td>
486642
<td class="results-item">
@@ -573,6 +729,7 @@ <h3>
573729
</ul>
574730
</td>
575731
<td>{{ item.score }}</td>
732+
<td>{{ item.std_dev|floatformat:2 }}</td>
576733
<td class="votes-list">
577734
<ul>
578735
{% for reviewer in item.userreview_set.all %}

backend/reviews/tests/test_admin.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,124 @@ def test_grants_review_scores(rf, scores, avg):
191191
assert grant_to_check.score == avg
192192

193193

194+
@pytest.mark.parametrize(
195+
"scores, expected_std_dev",
196+
[
197+
# Multiple different scores: mean=0.6, std_dev ≈ 1.744
198+
(
199+
[
200+
{"user": 0, "score": 2},
201+
{"user": 1, "score": 2},
202+
{"user": 2, "score": 2},
203+
{"user": 3, "score": -1},
204+
{"user": 4, "score": -2},
205+
],
206+
1.744,
207+
),
208+
# All same scores: std_dev = 0
209+
(
210+
[
211+
{"user": 0, "score": -2},
212+
{"user": 1, "score": -2},
213+
{"user": 2, "score": -2},
214+
{"user": 3, "score": -2},
215+
{"user": 4, "score": -2},
216+
],
217+
0.0,
218+
),
219+
# Single score: std_dev = 0
220+
(
221+
[
222+
{"user": 0, "score": 1},
223+
],
224+
0.0,
225+
),
226+
# No scores: std_dev = None
227+
([], None),
228+
# Two different scores (1 and -1): mean=0, std_dev = sqrt(((1-0)^2 + (-1-0)^2) / 2) = 1.0
229+
(
230+
[
231+
{"user": 0, "score": 1},
232+
{"user": 1, "score": -1},
233+
],
234+
1.0,
235+
),
236+
# Three scores with same value: std_dev = 0
237+
(
238+
[
239+
{"user": 0, "score": 1},
240+
{"user": 1, "score": 1},
241+
{"user": 2, "score": 1},
242+
],
243+
0.0,
244+
),
245+
# Mixed scores showing consensus with outlier: 3x score=1, 1x score=-2
246+
# mean = (1+1+1-2)/4 = 0.25
247+
# std_dev = sqrt(((1-0.25)^2 + (1-0.25)^2 + (1-0.25)^2 + (-2-0.25)^2) / 4)
248+
# = sqrt((0.5625 + 0.5625 + 0.5625 + 5.0625) / 4) = sqrt(1.6875) ≈ 1.299
249+
(
250+
[
251+
{"user": 0, "score": 1},
252+
{"user": 1, "score": 1},
253+
{"user": 2, "score": 1},
254+
{"user": 3, "score": -2},
255+
],
256+
1.299,
257+
),
258+
],
259+
)
260+
def test_grants_review_std_dev(rf, scores, expected_std_dev):
261+
conference = ConferenceFactory()
262+
review_session = ReviewSessionFactory(
263+
conference=conference,
264+
session_type=ReviewSession.SessionType.GRANTS,
265+
status=ReviewSession.Status.COMPLETED,
266+
)
267+
268+
users = UserFactory.create_batch(10, is_staff=True, is_superuser=True)
269+
all_scores = {
270+
-2: AvailableScoreOptionFactory(
271+
review_session=review_session, numeric_value=-2, label="Rejected"
272+
),
273+
-1: AvailableScoreOptionFactory(
274+
review_session=review_session, numeric_value=-1, label="Not convinced"
275+
),
276+
0: AvailableScoreOptionFactory(
277+
review_session=review_session, numeric_value=0, label="Maybe"
278+
),
279+
1: AvailableScoreOptionFactory(
280+
review_session=review_session, numeric_value=1, label="Yes"
281+
),
282+
2: AvailableScoreOptionFactory(
283+
review_session=review_session, numeric_value=2, label="Absolutely"
284+
),
285+
}
286+
287+
grant = GrantFactory(conference=conference)
288+
for score in scores:
289+
UserReviewFactory(
290+
review_session=review_session,
291+
grant=grant,
292+
user=users[score["user"]],
293+
score=all_scores[score["score"]],
294+
)
295+
296+
request = rf.get("/")
297+
request.user = users[5]
298+
299+
admin = ReviewSessionAdmin(ReviewSession, AdminSite())
300+
response = admin._review_grants_recap_view(request, review_session)
301+
context_data = response.context_data
302+
items = context_data["items"]
303+
grant_to_check = next(item for item in items if item.id == grant.id)
304+
305+
assert grant_to_check.id == grant.id
306+
if expected_std_dev is None:
307+
assert grant_to_check.std_dev is None
308+
else:
309+
assert grant_to_check.std_dev == pytest.approx(expected_std_dev, abs=0.01)
310+
311+
194312
def test_review_start_view_when_no_items_are_left(rf, mocker):
195313
mock_messages = mocker.patch("reviews.admin.messages")
196314

0 commit comments

Comments
 (0)