Skip to content

Commit 7a10e76

Browse files
Add leaderboard record flagging
1 parent 85d9e20 commit 7a10e76

16 files changed

Lines changed: 851 additions & 25 deletions

File tree

cadmin/templates/cadmin/course_admin.html

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,10 +130,15 @@ <h2 class="text-base font-semibold app-heading">Student support</h2>
130130
<p class="text-xs font-semibold uppercase app-muted">Hidden</p>
131131
<p class="mt-1 text-lg font-semibold app-heading">{{ support_metrics.hidden_leaderboard }}</p>
132132
</div>
133+
<div class="rounded-md app-surface-muted p-3">
134+
<p class="text-xs font-semibold uppercase app-muted">Open flags</p>
135+
<p class="mt-1 text-lg font-semibold app-heading">{{ support_metrics.open_complaints }}</p>
136+
</div>
133137
</div>
134138
<div class="mt-4 flex flex-wrap gap-2">
135139
<a href="{% url 'cadmin_enrollments' course.slug %}" class="primer-button cadmin-primary">Find student</a>
136140
<a href="{% url 'leaderboard' course.slug %}" class="primer-button primer-button-secondary">Open leaderboard</a>
141+
<a href="{% url 'cadmin_leaderboard_complaints' course.slug %}" class="primer-button primer-button-secondary">Review flags</a>
137142
</div>
138143
</article>
139144
</section>
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
{% extends 'cadmin/base.html' %}
2+
3+
{% block title %}Leaderboard flags - {{ course.title }}{% endblock %}
4+
5+
{% block breadcrumbs %}
6+
<li><a href="{% url 'cadmin_course_list' %}">Course Admin</a></li>
7+
<li><a href="{% url 'cadmin_course' course.slug %}">{{ course.title }}</a></li>
8+
<li>Leaderboard flags</li>
9+
{% endblock %}
10+
11+
{% block cadmin_content %}
12+
<section class="border-b app-border pb-5">
13+
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
14+
<div>
15+
<p class="text-sm font-semibold uppercase tracking-wide app-muted">Leaderboard moderation</p>
16+
<h1 class="mt-2 text-2xl font-semibold app-heading md:text-3xl">Flags for {{ course.title }}</h1>
17+
<p class="mt-1 text-sm app-muted">Student records are sorted by open flag count, then total flag count.</p>
18+
</div>
19+
<div class="flex flex-wrap gap-2">
20+
<a href="{% url 'cadmin_course' course.slug %}" class="primer-button primer-button-secondary">Back to Course Admin</a>
21+
<a href="{% url 'leaderboard' course.slug %}" class="primer-button">View Leaderboard</a>
22+
</div>
23+
</div>
24+
25+
<div class="mt-4 grid grid-cols-2 gap-2 sm:max-w-md">
26+
<div class="rounded-md border app-border app-surface-muted px-3 py-2">
27+
<span class="block text-xs font-semibold uppercase app-muted">Open</span>
28+
<span class="mt-1 block text-lg font-semibold app-heading">{{ open_complaints_count }}</span>
29+
</div>
30+
<div class="rounded-md border app-border app-surface-muted px-3 py-2">
31+
<span class="block text-xs font-semibold uppercase app-muted">Total</span>
32+
<span class="mt-1 block text-lg font-semibold app-heading">{{ total_complaints_count }}</span>
33+
</div>
34+
</div>
35+
</section>
36+
37+
<section class="mt-6 space-y-4">
38+
{% for row in enrollment_rows %}
39+
{% with enrollment=row.enrollment %}
40+
<article class="overflow-hidden rounded-md border app-border app-surface">
41+
<div class="border-b app-border app-surface-muted px-4 py-3">
42+
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
43+
<div>
44+
<h2 class="font-semibold app-heading">{{ enrollment.display_name }}</h2>
45+
<p class="mt-1 text-sm app-muted">
46+
{{ enrollment.student.email|default:enrollment.student.username }} ·
47+
position {% if enrollment.position_on_leaderboard %}{{ enrollment.position_on_leaderboard }}{% else %}-{% endif %} ·
48+
{{ enrollment.total_score }} points
49+
</p>
50+
</div>
51+
<div class="flex flex-wrap gap-2">
52+
<span class="rounded-md app-badge-danger px-2 py-1 text-sm font-semibold">{{ enrollment.open_complaints }} open</span>
53+
<span class="rounded-md app-badge-neutral px-2 py-1 text-sm font-semibold">{{ enrollment.total_complaints }} total</span>
54+
<a href="{% url 'leaderboard_score_breakdown' course.slug enrollment.id %}" class="primer-button primer-button-secondary">Scores</a>
55+
<a href="{% url 'cadmin_enrollment_edit' course.slug enrollment.id %}" class="primer-button primer-button-secondary">Manage</a>
56+
</div>
57+
</div>
58+
</div>
59+
<div class="divide-y app-divide">
60+
{% for complaint in row.complaints %}
61+
<div class="px-4 py-4">
62+
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
63+
<div class="min-w-0">
64+
<div class="flex flex-wrap items-center gap-2">
65+
<span class="font-medium app-heading">{{ complaint.get_issue_type_display }}</span>
66+
{% if complaint.resolved %}
67+
<span class="rounded-full app-badge-success px-2 py-0.5 text-xs font-medium">Resolved</span>
68+
{% else %}
69+
<span class="rounded-full app-badge-warning px-2 py-0.5 text-xs font-medium">Open</span>
70+
{% endif %}
71+
</div>
72+
<p class="mt-1 text-xs app-muted">
73+
Reported {{ complaint.created_at|date:"Y-m-d H:i" }}
74+
{% if complaint.reporter %}by {{ complaint.reporter.email|default:complaint.reporter.username }}{% endif %}
75+
</p>
76+
<p class="mt-3 whitespace-pre-wrap text-sm app-text">{{ complaint.description }}</p>
77+
{% if complaint.resolved %}
78+
<p class="mt-2 text-xs app-muted">
79+
Resolved {{ complaint.resolved_at|date:"Y-m-d H:i" }}
80+
{% if complaint.resolved_by %}by {{ complaint.resolved_by.email|default:complaint.resolved_by.username }}{% endif %}
81+
</p>
82+
{% endif %}
83+
</div>
84+
{% if not complaint.resolved %}
85+
<form method="post" action="{% url 'cadmin_leaderboard_complaint_resolve' course.slug complaint.id %}">
86+
{% csrf_token %}
87+
<button type="submit" class="primer-button">Mark resolved</button>
88+
</form>
89+
{% endif %}
90+
</div>
91+
</div>
92+
{% endfor %}
93+
</div>
94+
</article>
95+
{% endwith %}
96+
{% empty %}
97+
<div class="rounded-md border app-border app-surface p-6">
98+
<p class="text-sm app-muted">No leaderboard flags yet.</p>
99+
</div>
100+
{% endfor %}
101+
</section>
102+
{% endblock %}

cadmin/tests/test_views.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
ReviewCriteria,
1818
ReviewCriteriaTypes,
1919
ProjectEvaluationScore,
20+
LeaderboardComplaint,
2021
)
2122

2223

@@ -121,6 +122,95 @@ def test_course_admin_staff_allowed(self):
121122
self.assertContains(response, self.course.title)
122123
self.assertContains(response, "Admin Panel")
123124

125+
def test_leaderboard_complaints_sorted_by_open_count(self):
126+
self.client.login(username="admin@test.com", password="admin123")
127+
reporter = User.objects.create_user(
128+
username="reporter@test.com",
129+
email="reporter@test.com",
130+
password="12345",
131+
)
132+
first = Enrollment.objects.create(
133+
student=User.objects.create_user(username="first@test.com"),
134+
course=self.course,
135+
display_name="First Student",
136+
total_score=10,
137+
position_on_leaderboard=2,
138+
)
139+
second = Enrollment.objects.create(
140+
student=User.objects.create_user(username="second@test.com"),
141+
course=self.course,
142+
display_name="Second Student",
143+
total_score=20,
144+
position_on_leaderboard=1,
145+
)
146+
LeaderboardComplaint.objects.create(
147+
enrollment=first,
148+
reporter=reporter,
149+
issue_type=LeaderboardComplaint.IssueType.HOMEWORK,
150+
description="Incorrect homework.",
151+
)
152+
LeaderboardComplaint.objects.create(
153+
enrollment=second,
154+
reporter=reporter,
155+
issue_type=LeaderboardComplaint.IssueType.PROJECT,
156+
description="Incorrect project.",
157+
)
158+
LeaderboardComplaint.objects.create(
159+
enrollment=second,
160+
reporter=reporter,
161+
issue_type=LeaderboardComplaint.IssueType.LEARNING_IN_PUBLIC,
162+
description="Incorrect learning links.",
163+
)
164+
165+
response = self.client.get(
166+
reverse(
167+
"cadmin_leaderboard_complaints",
168+
kwargs={"course_slug": self.course.slug},
169+
)
170+
)
171+
172+
self.assertEqual(response.status_code, 200)
173+
rows = response.context["enrollment_rows"]
174+
self.assertEqual(rows[0]["enrollment"], second)
175+
self.assertEqual(rows[0]["enrollment"].open_complaints, 2)
176+
self.assertContains(response, "Second Student")
177+
178+
def test_staff_can_resolve_leaderboard_complaint(self):
179+
self.client.login(username="admin@test.com", password="admin123")
180+
enrollment = Enrollment.objects.create(
181+
student=self.user,
182+
course=self.course,
183+
display_name="Reported Student",
184+
total_score=10,
185+
)
186+
complaint = LeaderboardComplaint.objects.create(
187+
enrollment=enrollment,
188+
reporter=self.user,
189+
issue_type=LeaderboardComplaint.IssueType.HOMEWORK,
190+
description="Incorrect homework.",
191+
)
192+
193+
response = self.client.post(
194+
reverse(
195+
"cadmin_leaderboard_complaint_resolve",
196+
kwargs={
197+
"course_slug": self.course.slug,
198+
"complaint_id": complaint.id,
199+
},
200+
)
201+
)
202+
203+
self.assertRedirects(
204+
response,
205+
reverse(
206+
"cadmin_leaderboard_complaints",
207+
kwargs={"course_slug": self.course.slug},
208+
),
209+
)
210+
complaint.refresh_from_db()
211+
self.assertTrue(complaint.resolved)
212+
self.assertEqual(complaint.resolved_by, self.admin_user)
213+
124214
def test_homework_submissions_redirect_from_courses(self):
125215
"""Test that homework submissions view redirects to cadmin"""
126216
self.client.login(username="admin@test.com", password="admin123")

cadmin/urls.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,16 @@
5050
views.enrollments_list,
5151
name="cadmin_enrollments",
5252
),
53+
path(
54+
"<slug:course_slug>/leaderboard-complaints/",
55+
views.leaderboard_complaints,
56+
name="cadmin_leaderboard_complaints",
57+
),
58+
path(
59+
"<slug:course_slug>/leaderboard-complaints/<int:complaint_id>/resolve",
60+
views.leaderboard_complaint_resolve,
61+
name="cadmin_leaderboard_complaint_resolve",
62+
),
5363
path(
5464
"<slug:course_slug>/enrollment/<int:enrollment_id>/edit",
5565
views.enrollment_edit,

cadmin/views.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from django.shortcuts import render, get_object_or_404, redirect
88
from django.contrib import messages
99
from django.contrib.auth.decorators import user_passes_test
10+
from django.utils import timezone
1011

1112
from courses.models import (
1213
Course,
@@ -23,6 +24,7 @@
2324
ReviewCriteria,
2425
ProjectEvaluationScore,
2526
Enrollment,
27+
LeaderboardComplaint,
2628
)
2729
from courses.scoring import (
2830
score_homework_submissions,
@@ -109,13 +111,18 @@ def course_admin(request, course_slug):
109111
"disabled_lip": enrollments.filter(disable_learning_in_public=True).count(),
110112
"zero_score": enrollments.filter(total_score=0).count(),
111113
"hidden_leaderboard": enrollments.filter(display_on_leaderboard=False).count(),
114+
"open_complaints": LeaderboardComplaint.objects.filter(
115+
enrollment__course=course,
116+
resolved=False,
117+
).count(),
112118
}
113119
needs_attention_count = (
114120
len(homework_needing_score)
115121
+ len(projects_needing_reviews)
116122
+ len(projects_needing_score)
117123
+ support_metrics["disabled_lip"]
118124
+ support_metrics["hidden_leaderboard"]
125+
+ support_metrics["open_complaints"]
119126
)
120127
project_action_count = len(projects_needing_reviews) + len(projects_needing_score)
121128

@@ -620,6 +627,76 @@ def enrollments_list(request, course_slug):
620627
return render(request, "cadmin/enrollments.html", context)
621628

622629

630+
@staff_required
631+
def leaderboard_complaints(request, course_slug):
632+
course = get_object_or_404(Course, slug=course_slug)
633+
634+
enrollments = (
635+
Enrollment.objects.filter(course=course)
636+
.select_related("student")
637+
.annotate(
638+
open_complaints=Count(
639+
"complaints",
640+
filter=Q(complaints__resolved=False),
641+
),
642+
total_complaints=Count("complaints"),
643+
)
644+
.filter(total_complaints__gt=0)
645+
.order_by("-open_complaints", "-total_complaints", "position_on_leaderboard")
646+
)
647+
648+
complaints_by_enrollment = defaultdict(list)
649+
complaints = (
650+
LeaderboardComplaint.objects.filter(enrollment__course=course)
651+
.select_related("enrollment", "reporter", "resolved_by")
652+
.order_by("resolved", "-created_at")
653+
)
654+
for complaint in complaints:
655+
complaints_by_enrollment[complaint.enrollment_id].append(complaint)
656+
657+
enrollment_rows = []
658+
for enrollment in enrollments:
659+
enrollment_rows.append(
660+
{
661+
"enrollment": enrollment,
662+
"complaints": complaints_by_enrollment[enrollment.id],
663+
}
664+
)
665+
666+
context = {
667+
"course": course,
668+
"enrollment_rows": enrollment_rows,
669+
"open_complaints_count": LeaderboardComplaint.objects.filter(
670+
enrollment__course=course,
671+
resolved=False,
672+
).count(),
673+
"total_complaints_count": LeaderboardComplaint.objects.filter(
674+
enrollment__course=course,
675+
).count(),
676+
}
677+
return render(request, "cadmin/leaderboard_complaints.html", context)
678+
679+
680+
@staff_required
681+
def leaderboard_complaint_resolve(request, course_slug, complaint_id):
682+
if request.method != "POST":
683+
return redirect("cadmin_leaderboard_complaints", course_slug=course_slug)
684+
685+
course = get_object_or_404(Course, slug=course_slug)
686+
complaint = get_object_or_404(
687+
LeaderboardComplaint,
688+
id=complaint_id,
689+
enrollment__course=course,
690+
)
691+
complaint.resolved = True
692+
complaint.resolved_at = timezone.now()
693+
complaint.resolved_by = request.user
694+
complaint.save(update_fields=["resolved", "resolved_at", "resolved_by"])
695+
696+
messages.success(request, "Flag marked as resolved.")
697+
return redirect("cadmin_leaderboard_complaints", course_slug=course_slug)
698+
699+
623700
@staff_required
624701
def enrollment_edit(request, course_slug, enrollment_id):
625702
"""Edit an enrollment - mainly to disable learning in public"""

courses/admin/course.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
from django.contrib import messages
1212

13-
from courses.models import Course, ReviewCriteria
13+
from courses.models import Course, LeaderboardComplaint, ReviewCriteria
1414
from courses.scoring import update_leaderboard
1515
from courses.validators import validate_review_criteria_options
1616

@@ -130,3 +130,22 @@ class CourseAdmin(ModelAdmin):
130130
actions = [update_leaderboard_admin, duplicate_course]
131131
inlines = [CriteriaInline]
132132
list_display = ["title", "visible", "finished"]
133+
134+
135+
@admin.register(LeaderboardComplaint)
136+
class LeaderboardComplaintAdmin(ModelAdmin):
137+
list_display = [
138+
"enrollment",
139+
"issue_type",
140+
"resolved",
141+
"created_at",
142+
"resolved_at",
143+
]
144+
list_filter = ["resolved", "issue_type", "enrollment__course"]
145+
search_fields = [
146+
"description",
147+
"enrollment__display_name",
148+
"enrollment__student__email",
149+
"reporter__email",
150+
]
151+
readonly_fields = ["created_at"]

0 commit comments

Comments
 (0)