test: add gradebook API coverage

This commit is contained in:
2026-05-02 07:53:30 -06:00
parent 0998ee0f4b
commit 274b5b1981

View File

@@ -1,5 +1,10 @@
import csv
import io
from datetime import timedelta
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.utils import timezone
from account.models import RoleChoices
from prompt.models import Conversation, Message
@@ -10,16 +15,26 @@ from .models import Award, Submission, SubmissionAward
User = get_user_model()
def _make_user(username):
return User.objects.create_user(username=username, password="pw")
def _make_user(username, role=RoleChoices.NORMAL, classname=""):
user = User.objects.create_user(username=username, password="pw")
user.role = role
user.classname = classname
user.save(update_fields=["role", "classname"])
return user
def _make_task():
return Task.objects.create(
def _make_task(
title="Test Challenge",
task_type="challenge",
display=1,
is_public=True,
):
return Task.objects.create(
title=title,
task_type=task_type,
display=display,
content="",
is_public=is_public,
)
@@ -249,3 +264,272 @@ class ShowcaseManagementApiTest(TestCase):
f"/api/submission/showcase/{self.submission.id}/"
)
self.assertEqual(inactive_detail_resp.status_code, 404)
class GradebookApiTest(TestCase):
def setUp(self):
self.admin = _make_user("grade-admin", role=RoleChoices.ADMIN)
self.normal = _make_user("grade-normal", classname="blocked")
def _student(self, username, classname="10A"):
return _make_user(username, classname=classname)
def _submit(self, user, task, score, created=None):
submission = Submission.objects.create(
user=user,
task=task,
score=score,
html="",
css="",
js="",
)
if created is not None:
Submission.objects.filter(pk=submission.pk).update(created=created)
submission.refresh_from_db()
return submission
def test_gradebook_requires_classname(self):
self.client.force_login(self.admin)
resp = self.client.get("/api/submission/gradebook/")
self.assertEqual(resp.status_code, 400)
self.assertEqual(resp.json()["detail"], "请选择班级")
def test_normal_user_cannot_access_gradebook(self):
self.client.force_login(self.normal)
resp = self.client.get("/api/submission/gradebook/?classname=10A")
export_resp = self.client.get(
"/api/submission/gradebook/export/?classname=10A"
)
self.assertIn(resp.status_code, (302, 403))
self.assertIn(export_resp.status_code, (302, 403))
def test_coverage_includes_tutorial_and_challenge_without_public_requirement(self):
students = [
self._student("alice"),
self._student("bob"),
self._student("carol"),
self._student("dave"),
]
tutorial = _make_task(
title="Intro",
task_type="tutorial",
display=1,
is_public=False,
)
challenge = _make_task(
title="Challenge One",
task_type="challenge",
display=1,
is_public=True,
)
low_coverage = _make_task(
title="Optional",
task_type="challenge",
display=2,
is_public=True,
)
self._submit(students[0], tutorial, 4.0)
self._submit(students[1], tutorial, 5.0)
self._submit(students[0], challenge, 3.0)
self._submit(students[1], challenge, 4.0)
self._submit(students[0], low_coverage, 5.0)
self.client.force_login(self.admin)
resp = self.client.get("/api/submission/gradebook/?classname=10A")
self.assertEqual(resp.status_code, 200)
data = resp.json()
self.assertEqual(data["student_count"], 4)
self.assertEqual(data["coverage_threshold_count"], 2)
self.assertEqual(data["included_task_count"], 2)
self.assertEqual(
[task["id"] for task in data["tasks"]],
[tutorial.id, challenge.id],
)
self.assertTrue(all(task["included"] for task in data["tasks"]))
alice = next(row for row in data["rows"] if row["username"] == "alice")
carol = next(row for row in data["rows"] if row["username"] == "carol")
self.assertEqual(alice["tutorial_total"], 4.0)
self.assertEqual(alice["challenge_total"], 3.0)
self.assertEqual(alice["total_score"], 7.0)
self.assertEqual(alice["average_score"], 3.5)
self.assertEqual(alice["submitted_task_count"], 2)
self.assertEqual(alice["missing_task_count"], 0)
self.assertEqual(carol["total_score"], 0.0)
self.assertEqual(carol["submitted_task_count"], 0)
self.assertEqual(carol["missing_task_count"], 2)
include_all_resp = self.client.get(
"/api/submission/gradebook/?classname=10A&include_all_tasks=true"
)
include_all = include_all_resp.json()
optional = next(
task for task in include_all["tasks"] if task["id"] == low_coverage.id
)
alice_all = next(
row for row in include_all["rows"] if row["username"] == "alice"
)
self.assertFalse(optional["included"])
self.assertTrue(alice_all["scores"][str(low_coverage.id)]["submitted"])
self.assertEqual(alice_all["scores"][str(low_coverage.id)]["score"], 5.0)
self.assertEqual(alice_all["total_score"], 7.0)
self.assertEqual(alice_all["submitted_task_count"], 2)
def test_best_submission_uses_highest_score_and_latest_equal_score_link(self):
alice = self._student("alice")
bob = self._student("bob")
task = _make_task(title="Best Score", task_type="tutorial", display=3)
older = timezone.now() - timedelta(days=2)
newer = timezone.now() - timedelta(days=1)
self._submit(alice, task, 2.0)
old_best = self._submit(alice, task, 4.5, created=older)
new_best = self._submit(alice, task, 4.5, created=newer)
self._submit(bob, task, 3.0)
self.client.force_login(self.admin)
resp = self.client.get("/api/submission/gradebook/?classname=10A")
self.assertEqual(resp.status_code, 200)
alice_row = next(
row for row in resp.json()["rows"] if row["username"] == "alice"
)
cell = alice_row["scores"][str(task.id)]
self.assertEqual(cell["score"], 4.5)
self.assertEqual(cell["submission_id"], str(new_best.id))
self.assertNotEqual(cell["submission_id"], str(old_best.id))
def test_task_type_and_username_filters_keep_full_class_rank(self):
alice = self._student("alice")
bob = self._student("bob")
tutorial = _make_task(title="Tutorial", task_type="tutorial", display=1)
challenge = _make_task(title="Challenge", task_type="challenge", display=1)
self._submit(alice, tutorial, 1.0)
self._submit(bob, tutorial, 5.0)
self._submit(alice, challenge, 5.0)
self._submit(bob, challenge, 1.0)
self.client.force_login(self.admin)
resp = self.client.get(
"/api/submission/gradebook/?classname=10A&task_type=tutorial&username=alice"
)
self.assertEqual(resp.status_code, 200)
data = resp.json()
self.assertEqual([task["task_type"] for task in data["tasks"]], ["tutorial"])
self.assertEqual(len(data["rows"]), 1)
self.assertEqual(data["rows"][0]["username"], "alice")
self.assertEqual(data["rows"][0]["rank"], 2)
def test_missing_class_returns_empty_table_with_class_options(self):
self._student("alice")
self.client.force_login(self.admin)
resp = self.client.get("/api/submission/gradebook/?classname=missing")
self.assertEqual(resp.status_code, 200)
data = resp.json()
self.assertIn("10A", data["classes"])
self.assertEqual(data["student_count"], 0)
self.assertEqual(data["coverage_threshold_count"], 0)
self.assertEqual(data["tasks"], [])
self.assertEqual(data["rows"], [])
def test_no_included_tasks_still_returns_student_rows(self):
students = [
self._student("alice"),
self._student("bob"),
self._student("carol"),
]
optional = _make_task(title="Low Coverage", task_type="challenge", display=8)
self._submit(students[0], optional, 5.0)
self.client.force_login(self.admin)
resp = self.client.get("/api/submission/gradebook/?classname=10A")
self.assertEqual(resp.status_code, 200)
data = resp.json()
self.assertEqual(data["student_count"], 3)
self.assertEqual(data["coverage_threshold_count"], 2)
self.assertEqual(data["tasks"], [])
self.assertEqual(data["included_task_count"], 0)
self.assertEqual(len(data["rows"]), 3)
alice = next(row for row in data["rows"] if row["username"] == "alice")
self.assertEqual(alice["total_score"], 0.0)
self.assertIsNone(alice["average_score"])
self.assertEqual(alice["submitted_task_count"], 0)
self.assertEqual(alice["missing_task_count"], 0)
include_all_resp = self.client.get(
"/api/submission/gradebook/?classname=10A&include_all_tasks=true"
)
include_all = include_all_resp.json()
self.assertEqual(include_all["task_count"], 1)
self.assertFalse(include_all["tasks"][0]["included"])
alice_all = next(
row for row in include_all["rows"] if row["username"] == "alice"
)
self.assertTrue(alice_all["scores"][str(optional.id)]["submitted"])
self.assertEqual(alice_all["total_score"], 0.0)
def test_grade_boundaries_use_ceil_thresholds(self):
task = _make_task(title="Boundary", task_type="challenge", display=7)
for i in range(1, 21):
student = self._student(f"s{i:02d}", classname="10B")
self._submit(student, task, float(21 - i))
self.client.force_login(self.admin)
resp = self.client.get("/api/submission/gradebook/?classname=10B")
self.assertEqual(resp.status_code, 200)
rows_by_name = {row["username"]: row for row in resp.json()["rows"]}
self.assertEqual(rows_by_name["s01"]["grade"], "A")
self.assertEqual(rows_by_name["s06"]["grade"], "A")
self.assertEqual(rows_by_name["s07"]["grade"], "B")
self.assertEqual(rows_by_name["s14"]["grade"], "B")
self.assertEqual(rows_by_name["s15"]["grade"], "C")
self.assertEqual(rows_by_name["s18"]["grade"], "C")
self.assertEqual(rows_by_name["s19"]["grade"], "D")
self.assertEqual(rows_by_name["s20"]["grade"], "E")
def test_export_csv_matches_current_filters(self):
alice = self._student("alice")
bob = self._student("bob")
tutorial = _make_task(title="Intro", task_type="tutorial", display=1)
challenge = _make_task(title="Challenge", task_type="challenge", display=1)
self._submit(alice, tutorial, 4.0)
self._submit(bob, tutorial, 2.0)
self._submit(alice, challenge, 5.0)
self.client.force_login(self.admin)
resp = self.client.get(
"/api/submission/gradebook/export/?classname=10A&task_type=tutorial"
)
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp["Content-Type"], "text/csv; charset=utf-8")
self.assertIn("attachment;", resp["Content-Disposition"])
rows = list(csv.reader(io.StringIO(resp.content.decode("utf-8-sig"))))
self.assertEqual(
rows[0],
[
"排名",
"等级",
"用户名",
"班级",
"教程1-Intro",
"教程合计",
"挑战合计",
"总分",
"平均分",
"已提交任务数",
"未提交任务数",
],
)
self.assertEqual(rows[1][2], "alice")
self.assertEqual(rows[1][4], "4")
self.assertEqual(rows[1][7], "4")