Skip to content

Commit 4f3df95

Browse files
committed
feat: add course stats; fixes #19
1 parent 2675988 commit 4f3df95

File tree

9 files changed

+414
-30
lines changed

9 files changed

+414
-30
lines changed
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Generated by Django 4.1.13 on 2025-09-26 21:09
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('categories', '0017_2040413_merge_upstream'),
10+
]
11+
12+
operations = [
13+
migrations.CreateModel(
14+
name='CourseStats',
15+
fields=[
16+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
17+
('course_name', models.CharField(max_length=256)),
18+
('course_code', models.CharField(db_index=True, max_length=12)),
19+
('mean_mark', models.FloatField(blank=True, null=True)),
20+
('std_deviation', models.FloatField(blank=True, null=True)),
21+
('academic_year', models.CharField(max_length=10)),
22+
],
23+
options={
24+
'ordering': ['course_code', 'academic_year'],
25+
'unique_together': {('course_code', 'academic_year')},
26+
},
27+
),
28+
]

backend/categories/models.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,3 +86,20 @@ class EuclidCode(models.Model):
8686
category = models.ForeignKey(
8787
"Category", related_name="euclid_codes", on_delete=models.CASCADE
8888
)
89+
90+
91+
class CourseStats(models.Model):
92+
course_name = models.CharField(max_length=256)
93+
course_code = models.CharField(max_length=12, db_index=True)
94+
mean_mark = models.FloatField(null=True, blank=True) # Can be null for N/A values
95+
std_deviation = models.FloatField(
96+
null=True, blank=True
97+
) # Can be null for N/A values
98+
academic_year = models.CharField(max_length=10) # e.g. "2023-24"
99+
100+
class Meta:
101+
unique_together = ["course_code", "academic_year"]
102+
ordering = ["course_code", "academic_year"]
103+
104+
def __str__(self):
105+
return f"{self.course_code} ({self.academic_year}): {self.mean_mark}"

backend/categories/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,5 @@
3737
name="slugfromeuclidcode",
3838
),
3939
path("listeuclidcodes/", views.list_euclid_codes, name="listeuclidcodes"),
40+
path("stats/<slug:slug>/", views.get_course_stats, name="course_stats"),
4041
]

backend/categories/views.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from django.shortcuts import get_object_or_404
55

66
from answers.models import Answer
7-
from categories.models import Category, MetaCategory, EuclidCode
7+
from categories.models import Category, MetaCategory, EuclidCode, CourseStats
88
from ediauth import auth_check
99
from util import response, func_cache
1010

@@ -416,3 +416,31 @@ def list_euclid_codes(request):
416416
for code in codes
417417
]
418418
return response.success(value=res)
419+
420+
421+
@response.request_get()
422+
@auth_check.require_login
423+
def get_course_stats(request, slug):
424+
cat = get_object_or_404(Category, slug=slug)
425+
426+
# Get all Euclid codes for this category
427+
euclid_codes = list(cat.euclid_codes.all().values_list("code", flat=True))
428+
429+
if not euclid_codes:
430+
return response.success(value=[])
431+
432+
# Get course stats for all Euclid codes associated with this category
433+
stats = CourseStats.objects.filter(course_code__in=euclid_codes).order_by('course_code', 'academic_year')
434+
435+
res = [
436+
{
437+
"course_name": stat.course_name,
438+
"course_code": stat.course_code,
439+
"mean_mark": stat.mean_mark,
440+
"std_deviation": stat.std_deviation,
441+
"academic_year": stat.academic_year,
442+
}
443+
for stat in stats
444+
]
445+
446+
return response.success(value=res)

backend/testing/management/commands/create_testdata.py

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,13 @@
88
from ediauth.models import Profile
99
from answers.models import Answer, AnswerSection, Comment, Exam, ExamType
1010
from documents.models import DocumentType, Document, DocumentFile
11-
from categories.models import Category, MetaCategory, EuclidCode
11+
from categories.models import Category, MetaCategory, EuclidCode, CourseStats
1212
from feedback.models import Feedback
1313
from filestore.models import Attachment
1414
from images.models import Image
1515
from notifications.models import Notification, NotificationSetting, NotificationType
1616
import os
17+
import random
1718
from answers import pdf_utils
1819

1920

@@ -329,12 +330,87 @@ def create_documents(self):
329330
if (i + user.id) % 4 == 0:
330331
document.likes.add(user)
331332

333+
def create_course_stats(self):
334+
self.stdout.write("Create course statistics")
335+
336+
# Generate realistic course names for Informatics courses
337+
course_name_patterns = [
338+
"Algorithms and Data Structures",
339+
"Secure Programming",
340+
"Machine Learning",
341+
"Software Engineering and Professional Practice",
342+
"Introduction to Databases",
343+
"Systems Design Project",
344+
"Operating Systems",
345+
"Human-Computer Interaction",
346+
"Reasoning and Agents",
347+
"Cyber Security",
348+
"Applied Cloud Programming",
349+
"Distributed Systems",
350+
"Computer Vision",
351+
]
352+
353+
# Academic years from 2017-18 to 2024-25
354+
academic_years = [
355+
"2017-18",
356+
"2018-19",
357+
"2019-20",
358+
"2020-21",
359+
"2021-22",
360+
"2022-23",
361+
"2023-24",
362+
"2024-25",
363+
]
364+
365+
objs = []
366+
367+
# Get all Euclid codes from categories
368+
euclid_codes = EuclidCode.objects.all()
369+
370+
for euclid_code in euclid_codes:
371+
# Pick a random course name pattern
372+
base_course_name = random.choice(course_name_patterns)
373+
course_name = f"{base_course_name} ({euclid_code.category.displayname})"
374+
375+
# Generate stats for each academic year
376+
for year in academic_years:
377+
# Generate realistic grade statistics
378+
# Mean marks typically range from 45-85, with most courses 55-75
379+
base_mean = random.uniform(55, 75)
380+
381+
# Add some year-to-year variation (-5 to +5)
382+
year_variation = random.uniform(-5, 5)
383+
mean_mark = max(45, min(85, base_mean + year_variation))
384+
385+
# Standard deviation typically 10-25, with most 12-20
386+
std_deviation = random.uniform(12, 20)
387+
388+
# Some years might have missing data (simulate N/A values)
389+
if random.random() < 0.05: # 5% chance of missing data
390+
mean_mark = None
391+
std_deviation = None
392+
393+
objs.append(
394+
CourseStats(
395+
course_name=course_name,
396+
course_code=euclid_code.code,
397+
mean_mark=mean_mark,
398+
std_deviation=std_deviation,
399+
academic_year=year,
400+
)
401+
)
402+
403+
# Bulk create all course stats
404+
CourseStats.objects.bulk_create(objs, ignore_conflicts=True)
405+
self.stdout.write(f"Created {len(objs)} course statistics entries")
406+
332407
def handle(self, *args, **options):
333408
self.flush_db()
334409
self.create_users()
335410
self.create_images()
336411
self.create_meta_categories()
337412
self.create_categories()
413+
self.create_course_stats()
338414
self.create_exam_types()
339415
self.create_exams()
340416
self.create_answer_sections()

frontend/src/api/hooks.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
CategoryExam,
88
CategoryMetaData,
99
CategoryMetaDataMinimal,
10+
CourseStats,
1011
CutVersions,
1112
ExamMetaData,
1213
FeedbackEntry,
@@ -594,3 +595,16 @@ export const useRegenerateDocumentAPIKey = (
594595
documentSlug: string,
595596
onSuccess?: (res: Document) => void,
596597
) => useMutation(() => regenerateDocumentAPIKey(documentSlug), onSuccess);
598+
599+
// Course Stats
600+
export const loadCourseStats = async (slug: string) => {
601+
return (await fetchGet(`/api/category/stats/${slug}/`)).value as CourseStats[];
602+
};
603+
604+
export const useCourseStats = (slug: string) => {
605+
const { error, loading, data } = useRequest(() => loadCourseStats(slug), {
606+
cacheKey: `course-stats-${slug}`,
607+
refreshDeps: [slug],
608+
});
609+
return [error, loading, data] as const;
610+
};

0 commit comments

Comments
 (0)