Skip to content

Commit 2fcdb9c

Browse files
stats for projects + dashboard page
1 parent 8fd4106 commit 2fcdb9c

15 files changed

Lines changed: 1409 additions & 6 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Use uv for dependency management and running scripts within the project

courses/admin/projects.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,16 @@
33

44
from django.contrib import messages
55

6-
from courses.models import Project, ReviewCriteria
6+
from courses.models import Project, ReviewCriteria, ProjectState
77

88
from courses.projects import (
99
assign_peer_reviews_for_project,
1010
score_project,
1111
ProjectActionStatus,
1212
)
1313

14+
from courses.scoring import calculate_project_statistics
15+
1416

1517
def assign_peer_reviews_for_project_admin(
1618
modeladmin, request, queryset
@@ -48,11 +50,38 @@ def score_projects_admin(modeladmin, request, queryset):
4850
score_projects_admin.short_description = "Score projects"
4951

5052

53+
def calculate_statistics_selected_projects(
54+
modeladmin, request, queryset
55+
):
56+
for project in queryset:
57+
if project.state != ProjectState.COMPLETED.value:
58+
modeladmin.message_user(
59+
request,
60+
f"Cannot calculate statistics for {project} "
61+
"because it has not been completed",
62+
level=messages.WARNING,
63+
)
64+
continue
65+
66+
calculate_project_statistics(project, force=True)
67+
68+
message = f"Statistics calculated for {project}"
69+
modeladmin.message_user(
70+
request, message, level=messages.SUCCESS
71+
)
72+
73+
74+
calculate_statistics_selected_projects.short_description = (
75+
"Calculate statistics"
76+
)
77+
78+
5179
@admin.register(Project)
5280
class ProjectAdmin(ModelAdmin):
5381
actions = [
5482
assign_peer_reviews_for_project_admin,
5583
score_projects_admin,
84+
calculate_statistics_selected_projects,
5685
]
5786

5887
list_display = ["title", "course", "state"]
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# Generated by Django 5.2.4 on 2025-09-12 09:16
2+
3+
import django.db.models.deletion
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
('courses', '0021_course_min_projects_to_pass'),
11+
]
12+
13+
operations = [
14+
migrations.CreateModel(
15+
name='ProjectStatistics',
16+
fields=[
17+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
18+
('total_submissions', models.IntegerField(default=0)),
19+
('min_project_score', models.IntegerField(blank=True, null=True)),
20+
('max_project_score', models.IntegerField(blank=True, null=True)),
21+
('avg_project_score', models.FloatField(blank=True, null=True)),
22+
('median_project_score', models.FloatField(blank=True, null=True)),
23+
('q1_project_score', models.FloatField(blank=True, null=True)),
24+
('q3_project_score', models.FloatField(blank=True, null=True)),
25+
('min_project_learning_in_public_score', models.IntegerField(blank=True, null=True)),
26+
('max_project_learning_in_public_score', models.IntegerField(blank=True, null=True)),
27+
('avg_project_learning_in_public_score', models.FloatField(blank=True, null=True)),
28+
('median_project_learning_in_public_score', models.FloatField(blank=True, null=True)),
29+
('q1_project_learning_in_public_score', models.FloatField(blank=True, null=True)),
30+
('q3_project_learning_in_public_score', models.FloatField(blank=True, null=True)),
31+
('min_peer_review_score', models.IntegerField(blank=True, null=True)),
32+
('max_peer_review_score', models.IntegerField(blank=True, null=True)),
33+
('avg_peer_review_score', models.FloatField(blank=True, null=True)),
34+
('median_peer_review_score', models.FloatField(blank=True, null=True)),
35+
('q1_peer_review_score', models.FloatField(blank=True, null=True)),
36+
('q3_peer_review_score', models.FloatField(blank=True, null=True)),
37+
('min_peer_review_learning_in_public_score', models.IntegerField(blank=True, null=True)),
38+
('max_peer_review_learning_in_public_score', models.IntegerField(blank=True, null=True)),
39+
('avg_peer_review_learning_in_public_score', models.FloatField(blank=True, null=True)),
40+
('median_peer_review_learning_in_public_score', models.FloatField(blank=True, null=True)),
41+
('q1_peer_review_learning_in_public_score', models.FloatField(blank=True, null=True)),
42+
('q3_peer_review_learning_in_public_score', models.FloatField(blank=True, null=True)),
43+
('min_total_score', models.IntegerField(blank=True, null=True)),
44+
('max_total_score', models.IntegerField(blank=True, null=True)),
45+
('avg_total_score', models.FloatField(blank=True, null=True)),
46+
('median_total_score', models.FloatField(blank=True, null=True)),
47+
('q1_total_score', models.FloatField(blank=True, null=True)),
48+
('q3_total_score', models.FloatField(blank=True, null=True)),
49+
('min_time_spent', models.FloatField(blank=True, null=True)),
50+
('max_time_spent', models.FloatField(blank=True, null=True)),
51+
('avg_time_spent', models.FloatField(blank=True, null=True)),
52+
('median_time_spent', models.FloatField(blank=True, null=True)),
53+
('q1_time_spent', models.FloatField(blank=True, null=True)),
54+
('q3_time_spent', models.FloatField(blank=True, null=True)),
55+
('last_calculated', models.DateTimeField(auto_now=True)),
56+
('project', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='statistics', to='courses.project')),
57+
],
58+
),
59+
]

courses/models/project.py

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,3 +220,139 @@ class ProjectEvaluationScore(models.Model):
220220

221221
def __str__(self):
222222
return f"Score: {self.score} for submission by {self.submission.id}"
223+
224+
225+
class ProjectStatistics(models.Model):
226+
project = models.OneToOneField(
227+
Project, on_delete=models.CASCADE, related_name="statistics"
228+
)
229+
230+
total_submissions = models.IntegerField(default=0)
231+
232+
# Fields for project_score
233+
min_project_score = models.IntegerField(null=True, blank=True)
234+
max_project_score = models.IntegerField(null=True, blank=True)
235+
avg_project_score = models.FloatField(null=True, blank=True)
236+
median_project_score = models.FloatField(null=True, blank=True)
237+
q1_project_score = models.FloatField(null=True, blank=True)
238+
q3_project_score = models.FloatField(null=True, blank=True)
239+
240+
# Fields for project_learning_in_public_score
241+
min_project_learning_in_public_score = models.IntegerField(null=True, blank=True)
242+
max_project_learning_in_public_score = models.IntegerField(null=True, blank=True)
243+
avg_project_learning_in_public_score = models.FloatField(null=True, blank=True)
244+
median_project_learning_in_public_score = models.FloatField(null=True, blank=True)
245+
q1_project_learning_in_public_score = models.FloatField(null=True, blank=True)
246+
q3_project_learning_in_public_score = models.FloatField(null=True, blank=True)
247+
248+
# Fields for peer_review_score
249+
min_peer_review_score = models.IntegerField(null=True, blank=True)
250+
max_peer_review_score = models.IntegerField(null=True, blank=True)
251+
avg_peer_review_score = models.FloatField(null=True, blank=True)
252+
median_peer_review_score = models.FloatField(null=True, blank=True)
253+
q1_peer_review_score = models.FloatField(null=True, blank=True)
254+
q3_peer_review_score = models.FloatField(null=True, blank=True)
255+
256+
# Fields for peer_review_learning_in_public_score
257+
min_peer_review_learning_in_public_score = models.IntegerField(null=True, blank=True)
258+
max_peer_review_learning_in_public_score = models.IntegerField(null=True, blank=True)
259+
avg_peer_review_learning_in_public_score = models.FloatField(null=True, blank=True)
260+
median_peer_review_learning_in_public_score = models.FloatField(null=True, blank=True)
261+
q1_peer_review_learning_in_public_score = models.FloatField(null=True, blank=True)
262+
q3_peer_review_learning_in_public_score = models.FloatField(null=True, blank=True)
263+
264+
# Fields for total_score
265+
min_total_score = models.IntegerField(null=True, blank=True)
266+
max_total_score = models.IntegerField(null=True, blank=True)
267+
avg_total_score = models.FloatField(null=True, blank=True)
268+
median_total_score = models.FloatField(null=True, blank=True)
269+
q1_total_score = models.FloatField(null=True, blank=True)
270+
q3_total_score = models.FloatField(null=True, blank=True)
271+
272+
# Fields for time_spent
273+
min_time_spent = models.FloatField(null=True, blank=True)
274+
max_time_spent = models.FloatField(null=True, blank=True)
275+
avg_time_spent = models.FloatField(null=True, blank=True)
276+
median_time_spent = models.FloatField(null=True, blank=True)
277+
q1_time_spent = models.FloatField(null=True, blank=True)
278+
q3_time_spent = models.FloatField(null=True, blank=True)
279+
280+
last_calculated = models.DateTimeField(auto_now=True)
281+
282+
def get_value(self, field_name, stats_type):
283+
attribute_name = f"{stats_type}_{field_name}"
284+
return getattr(self, attribute_name)
285+
286+
def get_stat_fields(self):
287+
results = []
288+
289+
results.append(
290+
("Project score", [
291+
(self.min_project_score, "Minimum", "fas fa-arrow-down"),
292+
(self.max_project_score, "Maximum", "fas fa-arrow-up"),
293+
(self.avg_project_score, "Average", "fas fa-equals"),
294+
(self.q1_project_score, "25th Percentile", "fas fa-percentage"),
295+
(self.median_project_score, "Median", "fas fa-percentage"),
296+
(self.q3_project_score, "75th Percentile", "fas fa-percentage"),
297+
], 'fas fa-project-diagram')
298+
)
299+
300+
results.append(
301+
("Project learning in public score", [
302+
(self.min_project_learning_in_public_score, "Minimum", "fas fa-arrow-down"),
303+
(self.max_project_learning_in_public_score, "Maximum", "fas fa-arrow-up"),
304+
(self.avg_project_learning_in_public_score, "Average", "fas fa-equals"),
305+
(self.q1_project_learning_in_public_score, "25th Percentile", "fas fa-percentage"),
306+
(self.median_project_learning_in_public_score, "Median", "fas fa-percentage"),
307+
(self.q3_project_learning_in_public_score, "75th Percentile", "fas fa-percentage"),
308+
], 'fas fa-globe')
309+
)
310+
311+
results.append(
312+
("Peer review score", [
313+
(self.min_peer_review_score, "Minimum", "fas fa-arrow-down"),
314+
(self.max_peer_review_score, "Maximum", "fas fa-arrow-up"),
315+
(self.avg_peer_review_score, "Average", "fas fa-equals"),
316+
(self.q1_peer_review_score, "25th Percentile", "fas fa-percentage"),
317+
(self.median_peer_review_score, "Median", "fas fa-percentage"),
318+
(self.q3_peer_review_score, "75th Percentile", "fas fa-percentage"),
319+
], 'fas fa-users')
320+
)
321+
322+
results.append(
323+
("Peer review learning in public score", [
324+
(self.min_peer_review_learning_in_public_score, "Minimum", "fas fa-arrow-down"),
325+
(self.max_peer_review_learning_in_public_score, "Maximum", "fas fa-arrow-up"),
326+
(self.avg_peer_review_learning_in_public_score, "Average", "fas fa-equals"),
327+
(self.q1_peer_review_learning_in_public_score, "25th Percentile", "fas fa-percentage"),
328+
(self.median_peer_review_learning_in_public_score, "Median", "fas fa-percentage"),
329+
(self.q3_peer_review_learning_in_public_score, "75th Percentile", "fas fa-percentage"),
330+
], 'fas fa-share-alt')
331+
)
332+
333+
results.append(
334+
("Total score", [
335+
(self.min_total_score, "Minimum", "fas fa-arrow-down"),
336+
(self.max_total_score, "Maximum", "fas fa-arrow-up"),
337+
(self.avg_total_score, "Average", "fas fa-equals"),
338+
(self.q1_total_score, "25th Percentile", "fas fa-percentage"),
339+
(self.median_total_score, "Median", "fas fa-percentage"),
340+
(self.q3_total_score, "75th Percentile", "fas fa-percentage"),
341+
], 'fas fa-star')
342+
)
343+
344+
results.append(
345+
("Time spent on project", [
346+
(self.min_time_spent, "Minimum", "fas fa-arrow-down"),
347+
(self.max_time_spent, "Maximum", "fas fa-arrow-up"),
348+
(self.avg_time_spent, "Average", "fas fa-equals"),
349+
(self.q1_time_spent, "25th Percentile", "fas fa-percentage"),
350+
(self.median_time_spent, "Median", "fas fa-percentage"),
351+
(self.q3_time_spent, "75th Percentile", "fas fa-percentage"),
352+
], 'fas fa-clock')
353+
)
354+
355+
return results
356+
357+
def __str__(self):
358+
return f"Statistics for {self.project.slug}"

courses/scoring.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
Enrollment,
2525
Project,
2626
ProjectSubmission,
27+
ProjectStatistics,
28+
ProjectState,
2729
)
2830

2931

@@ -455,4 +457,93 @@ def calculate_homework_statistics(homework, force=False):
455457

456458
stats.save()
457459

460+
return stats
461+
462+
463+
def calculate_raw_project_statistics(project):
464+
submissions = ProjectSubmission.objects.filter(project=project)
465+
466+
total_submissions = submissions.count()
467+
468+
stats = {"total_submissions": total_submissions}
469+
470+
nones = {
471+
"min": None,
472+
"max": None,
473+
"avg": None,
474+
"q1": None,
475+
"median": None,
476+
"q3": None,
477+
}
478+
479+
fields = [
480+
"project_score",
481+
"project_learning_in_public_score",
482+
"peer_review_score",
483+
"peer_review_learning_in_public_score",
484+
"total_score",
485+
"time_spent",
486+
]
487+
488+
for field in fields:
489+
values = list(
490+
submissions.exclude(
491+
**{f"{field}__isnull": True}
492+
).values_list(field, flat=True)
493+
)
494+
495+
if not values or len(values) < 3:
496+
stats[field] = nones
497+
continue
498+
499+
quantiles = statistics.quantiles(
500+
values, n=4, method="inclusive"
501+
)
502+
503+
stats[field] = {
504+
"min": min(values),
505+
"max": max(values),
506+
"avg": statistics.mean(values),
507+
"q1": quantiles[0],
508+
"median": quantiles[1],
509+
"q3": quantiles[2],
510+
}
511+
512+
return stats
513+
514+
515+
def calculate_project_statistics(project, force=False):
516+
if project.state != ProjectState.COMPLETED.value:
517+
raise ValueError(
518+
f"Cannot calculate statistics for uncompleted project {project}"
519+
)
520+
521+
stats, created = ProjectStatistics.objects.get_or_create(
522+
project=project
523+
)
524+
525+
if force or created:
526+
calculated_stats = calculate_raw_project_statistics(project)
527+
528+
stats.total_submissions = calculated_stats["total_submissions"]
529+
530+
for field in [
531+
"project_score",
532+
"project_learning_in_public_score",
533+
"peer_review_score",
534+
"peer_review_learning_in_public_score",
535+
"total_score",
536+
"time_spent",
537+
]:
538+
field_stats = calculated_stats[field]
539+
540+
setattr(stats, f"min_{field}", field_stats["min"])
541+
setattr(stats, f"max_{field}", field_stats["max"])
542+
setattr(stats, f"avg_{field}", field_stats["avg"])
543+
setattr(stats, f"median_{field}", field_stats["median"])
544+
setattr(stats, f"q1_{field}", field_stats["q1"])
545+
setattr(stats, f"q3_{field}", field_stats["q3"])
546+
547+
stats.save()
548+
458549
return stats

courses/templates/courses/course.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ <h2>{{ course.title }}</h2>
1919
{% if course.first_homework_scored %}
2020
<a class="btn btn-primary" href="{% url 'leaderboard' course.slug %}" role="button">Course leaderboard</a>
2121
{% endif %}
22+
<a class="btn btn-secondary" href="{% url 'dashboard' course.slug %}" role="button">Course dashboard</a>
2223
{% if is_authenticated %}
2324
<a class="btn btn-info" href="{% url 'enrollment' course.slug %}" role="button">Edit course profile</a>
2425
{% endif %}

0 commit comments

Comments
 (0)