ygrader 1.2.4__tar.gz → 2.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {ygrader-1.2.4/ygrader.egg-info → ygrader-2.1.0}/PKG-INFO +1 -1
- {ygrader-1.2.4 → ygrader-2.1.0}/setup.py +1 -1
- {ygrader-1.2.4 → ygrader-2.1.0}/test/test_interactive.py +0 -2
- ygrader-2.1.0/test/test_unittest.py +139 -0
- ygrader-2.1.0/ygrader/__init__.py +10 -0
- {ygrader-1.2.4 → ygrader-2.1.0}/ygrader/deductions.py +51 -2
- ygrader-2.1.0/ygrader/feedback.py +383 -0
- {ygrader-1.2.4 → ygrader-2.1.0}/ygrader/grader.py +61 -70
- {ygrader-1.2.4 → ygrader-2.1.0}/ygrader/grades_csv.py +5 -5
- {ygrader-1.2.4 → ygrader-2.1.0}/ygrader/grading_item.py +80 -127
- ygrader-2.1.0/ygrader/grading_item_config.py +95 -0
- {ygrader-1.2.4 → ygrader-2.1.0}/ygrader/score_input.py +3 -16
- {ygrader-1.2.4 → ygrader-2.1.0/ygrader.egg-info}/PKG-INFO +1 -1
- ygrader-1.2.4/test/test_unittest.py +0 -105
- ygrader-1.2.4/ygrader/__init__.py +0 -7
- ygrader-1.2.4/ygrader/feedback.py +0 -223
- ygrader-1.2.4/ygrader/grading_item_config.py +0 -129
- {ygrader-1.2.4 → ygrader-2.1.0}/LICENSE +0 -0
- {ygrader-1.2.4 → ygrader-2.1.0}/setup.cfg +0 -0
- {ygrader-1.2.4 → ygrader-2.1.0}/ygrader/send_ctrl_backtick.ahk +0 -0
- {ygrader-1.2.4 → ygrader-2.1.0}/ygrader/student_repos.py +0 -0
- {ygrader-1.2.4 → ygrader-2.1.0}/ygrader/upstream_merger.py +0 -0
- {ygrader-1.2.4 → ygrader-2.1.0}/ygrader/utils.py +0 -0
- {ygrader-1.2.4 → ygrader-2.1.0}/ygrader.egg-info/SOURCES.txt +0 -0
- {ygrader-1.2.4 → ygrader-2.1.0}/ygrader.egg-info/dependency_links.txt +0 -0
- {ygrader-1.2.4 → ygrader-2.1.0}/ygrader.egg-info/requires.txt +0 -0
- {ygrader-1.2.4 → ygrader-2.1.0}/ygrader.egg-info/top_level.txt +0 -0
|
@@ -4,7 +4,7 @@ setup(
|
|
|
4
4
|
name="ygrader",
|
|
5
5
|
packages=["ygrader"],
|
|
6
6
|
package_data={"ygrader": ["*.ahk"]},
|
|
7
|
-
version="1.
|
|
7
|
+
version="2.1.0",
|
|
8
8
|
description="Grading scripts used in BYU's Electrical and Computer Engineering Department",
|
|
9
9
|
author="Jeff Goeders",
|
|
10
10
|
author_email="jeff.goeders@gmail.com",
|
|
@@ -35,12 +35,10 @@ def test_me():
|
|
|
35
35
|
"lab1",
|
|
36
36
|
run_on_milestone,
|
|
37
37
|
10,
|
|
38
|
-
help_msg="This is a long string\nWith lots of advice\nFor TAs",
|
|
39
38
|
)
|
|
40
39
|
grader.add_item_to_grade(
|
|
41
40
|
"lab1m2",
|
|
42
41
|
run_on_milestone,
|
|
43
|
-
help_msg="msg2",
|
|
44
42
|
)
|
|
45
43
|
grader.set_submission_system_learning_suite(TEST_RESOURCES_PATH / "submissions.zip")
|
|
46
44
|
grader.set_other_options(prep_fcn=run_on_lab)
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
#!/usr/bin/python3
|
|
2
|
+
|
|
3
|
+
import unittest
|
|
4
|
+
import pathlib
|
|
5
|
+
import sys
|
|
6
|
+
import filecmp
|
|
7
|
+
import doctest
|
|
8
|
+
|
|
9
|
+
ROOT_PATH = pathlib.Path(__file__).resolve().parent.parent
|
|
10
|
+
sys.path.append(str(ROOT_PATH))
|
|
11
|
+
|
|
12
|
+
import ygrader.student_repos
|
|
13
|
+
from ygrader import Grader, CodeSource
|
|
14
|
+
|
|
15
|
+
TEST_PATH = ROOT_PATH / "test"
|
|
16
|
+
TEST_RESOURCES_PATH = TEST_PATH / "resources"
|
|
17
|
+
TEST_OUTPUT_PATH = TEST_PATH / "grades"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def load_tests(loader, tests, ignore):
|
|
21
|
+
tests.addTests(doctest.DocTestSuite(ygrader.student_repos))
|
|
22
|
+
return tests
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TestGithub(unittest.TestCase):
|
|
26
|
+
def test_me(self):
|
|
27
|
+
class_list_csv_path = TEST_RESOURCES_PATH / "class_list_1.csv"
|
|
28
|
+
deductions1_golden_path = TEST_RESOURCES_PATH / "deductions1_golden.yaml"
|
|
29
|
+
deductions2_golden_path = TEST_RESOURCES_PATH / "deductions2_golden.yaml"
|
|
30
|
+
deductions1_yaml_path = TEST_OUTPUT_PATH / "deductions1.yaml"
|
|
31
|
+
deductions2_yaml_path = TEST_OUTPUT_PATH / "deductions2.yaml"
|
|
32
|
+
|
|
33
|
+
grader = Grader(
|
|
34
|
+
lab_name="github_test",
|
|
35
|
+
class_list_csv_path=class_list_csv_path,
|
|
36
|
+
work_path=TEST_PATH / "temp_github",
|
|
37
|
+
)
|
|
38
|
+
grader.add_item_to_grade(
|
|
39
|
+
"lab1",
|
|
40
|
+
self.runner,
|
|
41
|
+
max_points=10,
|
|
42
|
+
deductions_yaml_path=deductions1_yaml_path,
|
|
43
|
+
)
|
|
44
|
+
grader.add_item_to_grade(
|
|
45
|
+
"lab1m2",
|
|
46
|
+
self.runner,
|
|
47
|
+
max_points=20,
|
|
48
|
+
deductions_yaml_path=deductions2_yaml_path,
|
|
49
|
+
)
|
|
50
|
+
grader.set_submission_system_github(
|
|
51
|
+
"main", TEST_RESOURCES_PATH / "github.csv", use_https=True
|
|
52
|
+
)
|
|
53
|
+
grader.run()
|
|
54
|
+
|
|
55
|
+
self.assertTrue(filecmp.cmp(deductions1_yaml_path, deductions1_golden_path))
|
|
56
|
+
self.assertTrue(filecmp.cmp(deductions2_yaml_path, deductions2_golden_path))
|
|
57
|
+
|
|
58
|
+
def runner(self, **kw):
|
|
59
|
+
if kw["item_name"] == "lab1m2":
|
|
60
|
+
return [("New feedback", 2)]
|
|
61
|
+
return []
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class TestLearningSuite(unittest.TestCase):
|
|
65
|
+
def test_me(self):
|
|
66
|
+
class_list_csv_path = TEST_RESOURCES_PATH / "class_list_2.csv"
|
|
67
|
+
deductions_path = TEST_OUTPUT_PATH / "learningsuite_deductions.yaml"
|
|
68
|
+
deductions_golden_path = (
|
|
69
|
+
TEST_RESOURCES_PATH / "learningsuite_deductions_golden.yaml"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
grader = Grader(
|
|
73
|
+
lab_name="learningsuite_test",
|
|
74
|
+
class_list_csv_path=class_list_csv_path,
|
|
75
|
+
work_path=TEST_PATH / "temp_learningsuite",
|
|
76
|
+
)
|
|
77
|
+
grader.add_item_to_grade(
|
|
78
|
+
item_name="lab1",
|
|
79
|
+
grading_fcn=self.runner,
|
|
80
|
+
deductions_yaml_path=deductions_path,
|
|
81
|
+
max_points=10,
|
|
82
|
+
)
|
|
83
|
+
grader.set_submission_system_learning_suite(
|
|
84
|
+
TEST_RESOURCES_PATH / "submissions.zip"
|
|
85
|
+
)
|
|
86
|
+
grader.run()
|
|
87
|
+
|
|
88
|
+
self.assertTrue(filecmp.cmp(deductions_path, deductions_golden_path))
|
|
89
|
+
|
|
90
|
+
def runner(self, student_code_path, **kw):
|
|
91
|
+
self.assertIn("section", kw)
|
|
92
|
+
self.assertIn("homework_id", kw)
|
|
93
|
+
if (student_code_path / "file_1.txt").is_file() and (
|
|
94
|
+
student_code_path / "file_2.txt"
|
|
95
|
+
).is_file():
|
|
96
|
+
return []
|
|
97
|
+
else:
|
|
98
|
+
return [("Missing files", 10)]
|
|
99
|
+
|
|
100
|
+
def test_groups(self):
|
|
101
|
+
class_list_csv_path = TEST_RESOURCES_PATH / "class_list_3.csv"
|
|
102
|
+
deductions1_path = TEST_OUTPUT_PATH / "groups_l1.yaml"
|
|
103
|
+
deductions2_path = TEST_OUTPUT_PATH / "groups_l2.yaml"
|
|
104
|
+
deductions3_path = TEST_OUTPUT_PATH / "groups_l3.yaml"
|
|
105
|
+
deductions1_golden_path = TEST_RESOURCES_PATH / "groups_l1_golden.yaml"
|
|
106
|
+
deductions2_golden_path = TEST_RESOURCES_PATH / "groups_l2_golden.yaml"
|
|
107
|
+
deductions3_golden_path = TEST_RESOURCES_PATH / "groups_l3_golden.yaml"
|
|
108
|
+
|
|
109
|
+
grader = Grader(
|
|
110
|
+
"groups_test",
|
|
111
|
+
class_list_csv_path=class_list_csv_path,
|
|
112
|
+
work_path=TEST_PATH / "temp_groups",
|
|
113
|
+
)
|
|
114
|
+
grader.add_item_to_grade(
|
|
115
|
+
item_name="l1",
|
|
116
|
+
grading_fcn=self.group_grader_1,
|
|
117
|
+
deductions_yaml_path=deductions1_path,
|
|
118
|
+
max_points=10,
|
|
119
|
+
)
|
|
120
|
+
grader.add_item_to_grade(
|
|
121
|
+
item_name="l2",
|
|
122
|
+
grading_fcn=self.group_grader_2,
|
|
123
|
+
deductions_yaml_path=deductions2_path,
|
|
124
|
+
max_points=10,
|
|
125
|
+
)
|
|
126
|
+
grader.set_submission_system_learning_suite(
|
|
127
|
+
TEST_RESOURCES_PATH / "submissions2.zip"
|
|
128
|
+
)
|
|
129
|
+
grader.set_learning_suite_groups(TEST_RESOURCES_PATH / "groups3.csv")
|
|
130
|
+
grader.run()
|
|
131
|
+
|
|
132
|
+
self.assertTrue(filecmp.cmp(deductions1_path, deductions1_golden_path))
|
|
133
|
+
self.assertTrue(filecmp.cmp(deductions2_path, deductions2_golden_path))
|
|
134
|
+
|
|
135
|
+
def group_grader_1(self, **kw):
|
|
136
|
+
return []
|
|
137
|
+
|
|
138
|
+
def group_grader_2(self, **kw):
|
|
139
|
+
return [("Did not follow instructions", 5)]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Default imports for package"""
|
|
2
|
+
|
|
3
|
+
from .grader import Grader, CodeSource
|
|
4
|
+
from .upstream_merger import UpstreamMerger
|
|
5
|
+
from .utils import CallbackFailed
|
|
6
|
+
from .grading_item_config import (
|
|
7
|
+
LearningSuiteColumn,
|
|
8
|
+
LearningSuiteColumnParseError,
|
|
9
|
+
)
|
|
10
|
+
from .feedback import assemble_grades
|
|
@@ -138,9 +138,11 @@ class StudentDeductions:
|
|
|
138
138
|
all_student_keys = set(self.deductions_by_students.keys()) | set(
|
|
139
139
|
self.days_late_by_students.keys()
|
|
140
140
|
)
|
|
141
|
-
|
|
141
|
+
# Sort student keys for consistent output ordering
|
|
142
|
+
sorted_student_keys = sorted(all_student_keys)
|
|
143
|
+
if sorted_student_keys:
|
|
142
144
|
student_deduction_list = []
|
|
143
|
-
for student_key in
|
|
145
|
+
for student_key in sorted_student_keys:
|
|
144
146
|
deduction_items = self.deductions_by_students.get(student_key, [])
|
|
145
147
|
# Find the deduction IDs for these deduction items
|
|
146
148
|
deduction_ids = []
|
|
@@ -164,6 +166,9 @@ class StudentDeductions:
|
|
|
164
166
|
)
|
|
165
167
|
data["student_deductions"] = student_deduction_list
|
|
166
168
|
|
|
169
|
+
# Create parent directory if it doesn't exist
|
|
170
|
+
self.yaml_path.parent.mkdir(parents=True, exist_ok=True)
|
|
171
|
+
|
|
167
172
|
# Write to file
|
|
168
173
|
with open(self.yaml_path, "w", encoding="utf-8") as f:
|
|
169
174
|
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
|
@@ -191,6 +196,24 @@ class StudentDeductions:
|
|
|
191
196
|
self._save()
|
|
192
197
|
return next_id
|
|
193
198
|
|
|
199
|
+
def find_or_create_deduction_type(self, message: str, points: float = 0.0) -> int:
|
|
200
|
+
"""Find an existing deduction type by message, or create a new one if not found.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
message: The deduction message/description to find or create
|
|
204
|
+
points: Points to deduct (used only if creating a new type)
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
The ID of the existing or newly created deduction type
|
|
208
|
+
"""
|
|
209
|
+
# Search for existing deduction type with matching message
|
|
210
|
+
for deduction_id, deduction_type in self.deduction_types.items():
|
|
211
|
+
if deduction_type.message == message:
|
|
212
|
+
return deduction_id
|
|
213
|
+
|
|
214
|
+
# Not found, create a new one
|
|
215
|
+
return self.add_deduction_type(message, points)
|
|
216
|
+
|
|
194
217
|
def create_deduction_type_interactive(self) -> int:
|
|
195
218
|
"""Interactively prompt the user to create a new deduction type.
|
|
196
219
|
|
|
@@ -356,6 +379,32 @@ class StudentDeductions:
|
|
|
356
379
|
del self.days_late_by_students[student_key]
|
|
357
380
|
self._save()
|
|
358
381
|
|
|
382
|
+
def ensure_student_in_file(self, net_ids: tuple):
|
|
383
|
+
"""Ensure a student is in the deductions file, even with no deductions.
|
|
384
|
+
|
|
385
|
+
This is used to indicate that a student has been graded (with a perfect score)
|
|
386
|
+
rather than being absent from the file (not yet graded).
|
|
387
|
+
|
|
388
|
+
Args:
|
|
389
|
+
net_ids: Tuple of net_ids for the student.
|
|
390
|
+
"""
|
|
391
|
+
student_key = tuple(net_ids) if not isinstance(net_ids, tuple) else net_ids
|
|
392
|
+
if student_key not in self.deductions_by_students:
|
|
393
|
+
self.deductions_by_students[student_key] = []
|
|
394
|
+
self._save()
|
|
395
|
+
|
|
396
|
+
def is_student_graded(self, net_ids: tuple) -> bool:
|
|
397
|
+
"""Check if a student has been graded (is in the deductions file).
|
|
398
|
+
|
|
399
|
+
Args:
|
|
400
|
+
net_ids: Tuple of net_ids for the student.
|
|
401
|
+
|
|
402
|
+
Returns:
|
|
403
|
+
True if the student is in the deductions file (graded), False otherwise.
|
|
404
|
+
"""
|
|
405
|
+
student_key = tuple(net_ids) if not isinstance(net_ids, tuple) else net_ids
|
|
406
|
+
return student_key in self.deductions_by_students
|
|
407
|
+
|
|
359
408
|
def set_days_late(self, net_ids: tuple, days_late: int):
|
|
360
409
|
"""Set the number of days late for a student.
|
|
361
410
|
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
"""Module for generating student feedback files and grades CSV."""
|
|
2
|
+
|
|
3
|
+
import pathlib
|
|
4
|
+
import zipfile
|
|
5
|
+
from typing import Callable, Dict, Optional, Tuple
|
|
6
|
+
|
|
7
|
+
import pandas
|
|
8
|
+
|
|
9
|
+
from .deductions import StudentDeductions
|
|
10
|
+
from .grading_item_config import LearningSuiteColumn
|
|
11
|
+
from .utils import warning
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# Type alias for late penalty callback: (late_days, max_score, actual_score) -> new_score
|
|
15
|
+
LatePenaltyCallback = Callable[[int, float, float], float]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _get_student_key_and_max_late_days(
|
|
19
|
+
net_id: str,
|
|
20
|
+
item_deductions: Dict[str, StudentDeductions],
|
|
21
|
+
) -> tuple:
|
|
22
|
+
"""Find the student key and maximum late days across all items.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
net_id: The student's net ID.
|
|
26
|
+
item_deductions: Mapping from item name to StudentDeductions.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
Tuple of (student_key or None, max_late_days).
|
|
30
|
+
"""
|
|
31
|
+
max_late_days = 0
|
|
32
|
+
found_student_key = None
|
|
33
|
+
|
|
34
|
+
for deductions_obj in item_deductions.values():
|
|
35
|
+
if not deductions_obj:
|
|
36
|
+
continue
|
|
37
|
+
|
|
38
|
+
# Find the student key
|
|
39
|
+
student_key = None
|
|
40
|
+
if (net_id,) in deductions_obj.deductions_by_students:
|
|
41
|
+
student_key = (net_id,)
|
|
42
|
+
elif (net_id,) in deductions_obj.days_late_by_students:
|
|
43
|
+
student_key = (net_id,)
|
|
44
|
+
else:
|
|
45
|
+
# Check for multi-student keys containing this net_id
|
|
46
|
+
for key in set(deductions_obj.deductions_by_students.keys()) | set(
|
|
47
|
+
deductions_obj.days_late_by_students.keys()
|
|
48
|
+
):
|
|
49
|
+
if net_id in key:
|
|
50
|
+
student_key = key
|
|
51
|
+
break
|
|
52
|
+
|
|
53
|
+
if student_key:
|
|
54
|
+
found_student_key = student_key
|
|
55
|
+
days_late = deductions_obj.days_late_by_students.get(student_key, 0)
|
|
56
|
+
max_late_days = max(max_late_days, days_late)
|
|
57
|
+
|
|
58
|
+
return found_student_key, max_late_days
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _calculate_student_score(
|
|
62
|
+
net_id: str,
|
|
63
|
+
ls_column: LearningSuiteColumn,
|
|
64
|
+
item_deductions: Dict[str, StudentDeductions],
|
|
65
|
+
late_penalty_callback: Optional[LatePenaltyCallback] = None,
|
|
66
|
+
warn_on_missing_callback: bool = True,
|
|
67
|
+
) -> Tuple[float, float, int]:
|
|
68
|
+
"""Calculate a student's final score.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
net_id: The student's net ID.
|
|
72
|
+
ls_column: The LearningSuiteColumn configuration.
|
|
73
|
+
item_deductions: Mapping from item name to StudentDeductions.
|
|
74
|
+
late_penalty_callback: Optional callback for late penalty.
|
|
75
|
+
warn_on_missing_callback: Whether to warn if late days found but no callback.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
Tuple of (final_score, total_possible, max_late_days).
|
|
79
|
+
"""
|
|
80
|
+
total_possible = sum(item.points for item in ls_column.items)
|
|
81
|
+
total_deductions = 0.0
|
|
82
|
+
|
|
83
|
+
for item in ls_column.items:
|
|
84
|
+
deductions_obj = item_deductions.get(item.name)
|
|
85
|
+
if deductions_obj:
|
|
86
|
+
# Find the student's deductions
|
|
87
|
+
student_key = None
|
|
88
|
+
if (net_id,) in deductions_obj.deductions_by_students:
|
|
89
|
+
student_key = (net_id,)
|
|
90
|
+
else:
|
|
91
|
+
for key in deductions_obj.deductions_by_students.keys():
|
|
92
|
+
if net_id in key:
|
|
93
|
+
student_key = key
|
|
94
|
+
break
|
|
95
|
+
|
|
96
|
+
if student_key:
|
|
97
|
+
deductions = deductions_obj.deductions_by_students[student_key]
|
|
98
|
+
for deduction in deductions:
|
|
99
|
+
total_deductions += deduction.points
|
|
100
|
+
|
|
101
|
+
# Calculate score before late penalty
|
|
102
|
+
score = max(0, total_possible - total_deductions)
|
|
103
|
+
|
|
104
|
+
# Get max late days
|
|
105
|
+
_, max_late_days = _get_student_key_and_max_late_days(net_id, item_deductions)
|
|
106
|
+
|
|
107
|
+
# Apply late penalty if applicable
|
|
108
|
+
if max_late_days > 0:
|
|
109
|
+
if late_penalty_callback:
|
|
110
|
+
score = max(0, late_penalty_callback(max_late_days, total_possible, score))
|
|
111
|
+
elif warn_on_missing_callback:
|
|
112
|
+
warning(
|
|
113
|
+
f"Student {net_id} has {max_late_days} late day(s) but no late penalty callback provided"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
return score, total_possible, max_late_days
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def assemble_grades(
|
|
120
|
+
yaml_path: pathlib.Path,
|
|
121
|
+
class_list_csv_path: pathlib.Path,
|
|
122
|
+
subitem_feedback_paths: Dict[str, pathlib.Path],
|
|
123
|
+
*,
|
|
124
|
+
output_zip_path: Optional[pathlib.Path] = None,
|
|
125
|
+
output_csv_path: Optional[pathlib.Path] = None,
|
|
126
|
+
late_penalty_callback: Optional[LatePenaltyCallback] = None,
|
|
127
|
+
) -> Tuple[Optional[pathlib.Path], Optional[pathlib.Path]]:
|
|
128
|
+
"""Generate feedback zip and/or grades CSV from deductions.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
yaml_path: Path to the YAML file that can be loaded by LearningSuiteColumn.
|
|
132
|
+
class_list_csv_path: Path to CSV file with class list (Net ID, First Name, Last Name).
|
|
133
|
+
subitem_feedback_paths: Mapping from subitem name to feedback YAML file path.
|
|
134
|
+
output_zip_path: Path for the output zip file. If None, no zip is generated.
|
|
135
|
+
output_csv_path: Path for the output CSV file. If None, no CSV is generated.
|
|
136
|
+
late_penalty_callback: Optional callback function that takes
|
|
137
|
+
(late_days, max_score, actual_score) and returns the adjusted score.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
Tuple of (feedback_zip_path or None, grades_csv_path or None).
|
|
141
|
+
"""
|
|
142
|
+
yaml_path = pathlib.Path(yaml_path)
|
|
143
|
+
ls_column = LearningSuiteColumn(yaml_path)
|
|
144
|
+
|
|
145
|
+
# Get the lab name from the YAML file's parent directory
|
|
146
|
+
lab_name = yaml_path.parent.name
|
|
147
|
+
|
|
148
|
+
# Load all student deductions for each subitem
|
|
149
|
+
subitem_deductions: Dict[str, StudentDeductions] = {}
|
|
150
|
+
for subitem in ls_column.items:
|
|
151
|
+
if subitem.name in subitem_feedback_paths:
|
|
152
|
+
feedback_path = subitem_feedback_paths[subitem.name]
|
|
153
|
+
if feedback_path.exists():
|
|
154
|
+
subitem_deductions[subitem.name] = StudentDeductions(feedback_path)
|
|
155
|
+
else:
|
|
156
|
+
subitem_deductions[subitem.name] = StudentDeductions()
|
|
157
|
+
else:
|
|
158
|
+
subitem_deductions[subitem.name] = StudentDeductions()
|
|
159
|
+
|
|
160
|
+
# Load class list
|
|
161
|
+
students_df = pandas.read_csv(class_list_csv_path)
|
|
162
|
+
|
|
163
|
+
# Prepare for CSV output
|
|
164
|
+
grades_data = []
|
|
165
|
+
|
|
166
|
+
def process_students(zip_file: Optional[zipfile.ZipFile]) -> None:
|
|
167
|
+
"""Process all students, adding to grades_data and optionally writing to zip."""
|
|
168
|
+
for _, student_row in students_df.iterrows():
|
|
169
|
+
first_name = str(student_row["First Name"]).strip()
|
|
170
|
+
last_name = str(student_row["Last Name"]).strip()
|
|
171
|
+
net_id = str(student_row["Net ID"]).strip()
|
|
172
|
+
|
|
173
|
+
# Calculate final score (only warn once, when generating CSV)
|
|
174
|
+
final_score, _, _ = _calculate_student_score(
|
|
175
|
+
net_id=net_id,
|
|
176
|
+
ls_column=ls_column,
|
|
177
|
+
item_deductions=subitem_deductions,
|
|
178
|
+
late_penalty_callback=late_penalty_callback,
|
|
179
|
+
warn_on_missing_callback=(output_csv_path is not None),
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
# Add to grades data
|
|
183
|
+
if output_csv_path:
|
|
184
|
+
grades_data.append(
|
|
185
|
+
{"Net ID": net_id, ls_column.csv_col_name: final_score}
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# Generate feedback file
|
|
189
|
+
if zip_file:
|
|
190
|
+
feedback_content = _generate_student_feedback(
|
|
191
|
+
student_row=student_row,
|
|
192
|
+
ls_column=ls_column,
|
|
193
|
+
subitem_deductions=subitem_deductions,
|
|
194
|
+
late_penalty_callback=late_penalty_callback,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
filename = (
|
|
198
|
+
first_name
|
|
199
|
+
+ "_"
|
|
200
|
+
+ last_name
|
|
201
|
+
+ "_"
|
|
202
|
+
+ net_id
|
|
203
|
+
+ "_feedback-"
|
|
204
|
+
+ lab_name
|
|
205
|
+
+ ".txt"
|
|
206
|
+
)
|
|
207
|
+
zip_file.writestr(filename, feedback_content)
|
|
208
|
+
|
|
209
|
+
# Process students with or without zip file
|
|
210
|
+
if output_zip_path:
|
|
211
|
+
with zipfile.ZipFile(output_zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
212
|
+
process_students(zf)
|
|
213
|
+
else:
|
|
214
|
+
process_students(None)
|
|
215
|
+
|
|
216
|
+
# Write CSV (sorted by Net ID for easier git diffs)
|
|
217
|
+
if output_csv_path and grades_data:
|
|
218
|
+
grades_df = pandas.DataFrame(grades_data)
|
|
219
|
+
grades_df = grades_df.sort_values("Net ID")
|
|
220
|
+
grades_df.to_csv(output_csv_path, index=False)
|
|
221
|
+
|
|
222
|
+
return (output_zip_path, output_csv_path)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _generate_student_feedback(
|
|
226
|
+
student_row: pandas.Series,
|
|
227
|
+
ls_column: LearningSuiteColumn,
|
|
228
|
+
subitem_deductions: Dict[str, StudentDeductions],
|
|
229
|
+
late_penalty_callback: Optional[LatePenaltyCallback] = None,
|
|
230
|
+
) -> str:
|
|
231
|
+
"""Generate the feedback text content for a single student.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
student_row: A row from the student DataFrame.
|
|
235
|
+
ls_column: The LearningSuiteColumn object.
|
|
236
|
+
subitem_deductions: Mapping from subitem name to StudentDeductions.
|
|
237
|
+
late_penalty_callback: Optional callback for calculating late penalty.
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
The formatted feedback text.
|
|
241
|
+
"""
|
|
242
|
+
net_id = str(student_row["Net ID"]).strip()
|
|
243
|
+
first_name = str(student_row["First Name"]).strip()
|
|
244
|
+
last_name = str(student_row["Last Name"]).strip()
|
|
245
|
+
|
|
246
|
+
lines = []
|
|
247
|
+
lines.append(f"Feedback for {first_name} {last_name} ({net_id})")
|
|
248
|
+
lines.append("=" * 60)
|
|
249
|
+
lines.append("")
|
|
250
|
+
|
|
251
|
+
total_points_possible = 0
|
|
252
|
+
total_points_deducted = 0
|
|
253
|
+
|
|
254
|
+
# Column widths for formatting
|
|
255
|
+
# Total width is 60 characters to match separator lines
|
|
256
|
+
# Item name column + score column (e.g., "10.0 / 10.0")
|
|
257
|
+
item_col_width = 42
|
|
258
|
+
score_col_width = 17
|
|
259
|
+
# For deduction lines: " - " prefix (4 chars) + message + points
|
|
260
|
+
deduction_prefix = " - "
|
|
261
|
+
deduction_msg_width = 38
|
|
262
|
+
deduction_pts_width = 17
|
|
263
|
+
|
|
264
|
+
for item in ls_column.items:
|
|
265
|
+
subitem_points_possible = item.points
|
|
266
|
+
total_points_possible += subitem_points_possible
|
|
267
|
+
|
|
268
|
+
subitem_points_deducted = 0
|
|
269
|
+
item_deductions = []
|
|
270
|
+
|
|
271
|
+
# Get deductions for this student in this item
|
|
272
|
+
student_deductions_obj = subitem_deductions.get(item.name)
|
|
273
|
+
if student_deductions_obj:
|
|
274
|
+
# Find the student's deductions (try single net_id first, then tuple)
|
|
275
|
+
student_key = None
|
|
276
|
+
if (net_id,) in student_deductions_obj.deductions_by_students:
|
|
277
|
+
student_key = (net_id,)
|
|
278
|
+
else:
|
|
279
|
+
# Check for multi-student keys containing this net_id
|
|
280
|
+
for key in student_deductions_obj.deductions_by_students.keys():
|
|
281
|
+
if net_id in key:
|
|
282
|
+
student_key = key
|
|
283
|
+
break
|
|
284
|
+
|
|
285
|
+
if student_key:
|
|
286
|
+
deductions = student_deductions_obj.deductions_by_students[student_key]
|
|
287
|
+
for deduction in deductions:
|
|
288
|
+
item_deductions.append((deduction.message, deduction.points))
|
|
289
|
+
subitem_points_deducted += deduction.points
|
|
290
|
+
|
|
291
|
+
# Calculate item score
|
|
292
|
+
subitem_score = max(0, subitem_points_possible - subitem_points_deducted)
|
|
293
|
+
score_str = f"{subitem_score:.1f} / {subitem_points_possible:.1f}"
|
|
294
|
+
|
|
295
|
+
# Item line with score
|
|
296
|
+
item_name_with_colon = f"{item.name}:"
|
|
297
|
+
lines.append(
|
|
298
|
+
f"{item_name_with_colon:<{item_col_width}} {score_str:>{score_col_width}}"
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
# Deduction lines (indented)
|
|
302
|
+
for msg, pts in item_deductions:
|
|
303
|
+
# Wrap long messages
|
|
304
|
+
wrapped = _wrap_text(msg, deduction_msg_width)
|
|
305
|
+
for i, line_text in enumerate(wrapped):
|
|
306
|
+
if i == 0:
|
|
307
|
+
lines.append(
|
|
308
|
+
f"{deduction_prefix}{line_text:<{deduction_msg_width}} {-pts:>{deduction_pts_width}.1f}"
|
|
309
|
+
)
|
|
310
|
+
else:
|
|
311
|
+
# Continuation lines - no points
|
|
312
|
+
lines.append(f"{deduction_prefix}{line_text}")
|
|
313
|
+
|
|
314
|
+
total_points_deducted += subitem_points_deducted
|
|
315
|
+
|
|
316
|
+
# Calculate score before late penalty (clamped to 0)
|
|
317
|
+
score_before_late = max(0, total_points_possible - total_points_deducted)
|
|
318
|
+
|
|
319
|
+
# Get max late days for this student
|
|
320
|
+
_, max_late_days = _get_student_key_and_max_late_days(net_id, subitem_deductions)
|
|
321
|
+
|
|
322
|
+
# Late penalty section
|
|
323
|
+
lines.append("")
|
|
324
|
+
lines.append("=" * 60)
|
|
325
|
+
if max_late_days > 0 and late_penalty_callback:
|
|
326
|
+
final_score = late_penalty_callback(
|
|
327
|
+
max_late_days, total_points_possible, score_before_late
|
|
328
|
+
)
|
|
329
|
+
# Ensure final score is not negative
|
|
330
|
+
final_score = max(0, final_score)
|
|
331
|
+
late_penalty_points = score_before_late - final_score
|
|
332
|
+
late_label = (
|
|
333
|
+
f"Late Penalty ({max_late_days} day{'s' if max_late_days != 1 else ''}):"
|
|
334
|
+
)
|
|
335
|
+
lines.append(
|
|
336
|
+
f"{late_label:<{item_col_width}} {-late_penalty_points:>{score_col_width}.1f}"
|
|
337
|
+
)
|
|
338
|
+
else:
|
|
339
|
+
final_score = score_before_late
|
|
340
|
+
lines.append(
|
|
341
|
+
f"{'Late Penalty:':<{item_col_width}} {'On Time':>{score_col_width}}"
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
# Total score section
|
|
345
|
+
total_score_str = f"{final_score:.1f} / {total_points_possible:.1f}"
|
|
346
|
+
lines.append(
|
|
347
|
+
f"{'TOTAL SCORE:':<{item_col_width}} {total_score_str:>{score_col_width}}"
|
|
348
|
+
)
|
|
349
|
+
lines.append("=" * 60)
|
|
350
|
+
|
|
351
|
+
return "\n".join(lines)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _wrap_text(text: str, width: int) -> list:
|
|
355
|
+
"""Wrap text to fit within a given width.
|
|
356
|
+
|
|
357
|
+
Args:
|
|
358
|
+
text: The text to wrap.
|
|
359
|
+
width: Maximum width for each line.
|
|
360
|
+
|
|
361
|
+
Returns:
|
|
362
|
+
List of wrapped lines.
|
|
363
|
+
"""
|
|
364
|
+
if len(text) <= width:
|
|
365
|
+
return [text]
|
|
366
|
+
|
|
367
|
+
words = text.split()
|
|
368
|
+
lines = []
|
|
369
|
+
current_line = ""
|
|
370
|
+
|
|
371
|
+
for word in words:
|
|
372
|
+
if not current_line:
|
|
373
|
+
current_line = word
|
|
374
|
+
elif len(current_line) + 1 + len(word) <= width:
|
|
375
|
+
current_line += " " + word
|
|
376
|
+
else:
|
|
377
|
+
lines.append(current_line)
|
|
378
|
+
current_line = word
|
|
379
|
+
|
|
380
|
+
if current_line:
|
|
381
|
+
lines.append(current_line)
|
|
382
|
+
|
|
383
|
+
return lines if lines else [""]
|