ygrader 2.0.0__tar.gz → 2.2.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-2.0.0/ygrader.egg-info → ygrader-2.2.0}/PKG-INFO +1 -1
- {ygrader-2.0.0 → ygrader-2.2.0}/setup.py +1 -1
- {ygrader-2.0.0 → ygrader-2.2.0}/ygrader/__init__.py +1 -1
- {ygrader-2.0.0 → ygrader-2.2.0}/ygrader/deductions.py +21 -21
- ygrader-2.2.0/ygrader/feedback.py +476 -0
- {ygrader-2.0.0 → ygrader-2.2.0}/ygrader/grader.py +0 -48
- {ygrader-2.0.0 → ygrader-2.2.0}/ygrader/grading_item.py +12 -49
- {ygrader-2.0.0 → ygrader-2.2.0}/ygrader/grading_item_config.py +1 -5
- {ygrader-2.0.0 → ygrader-2.2.0/ygrader.egg-info}/PKG-INFO +1 -1
- ygrader-2.0.0/ygrader/feedback.py +0 -218
- {ygrader-2.0.0 → ygrader-2.2.0}/LICENSE +0 -0
- {ygrader-2.0.0 → ygrader-2.2.0}/setup.cfg +0 -0
- {ygrader-2.0.0 → ygrader-2.2.0}/test/test_interactive.py +0 -0
- {ygrader-2.0.0 → ygrader-2.2.0}/test/test_unittest.py +0 -0
- {ygrader-2.0.0 → ygrader-2.2.0}/ygrader/grades_csv.py +0 -0
- {ygrader-2.0.0 → ygrader-2.2.0}/ygrader/score_input.py +0 -0
- {ygrader-2.0.0 → ygrader-2.2.0}/ygrader/send_ctrl_backtick.ahk +0 -0
- {ygrader-2.0.0 → ygrader-2.2.0}/ygrader/student_repos.py +0 -0
- {ygrader-2.0.0 → ygrader-2.2.0}/ygrader/upstream_merger.py +0 -0
- {ygrader-2.0.0 → ygrader-2.2.0}/ygrader/utils.py +0 -0
- {ygrader-2.0.0 → ygrader-2.2.0}/ygrader.egg-info/SOURCES.txt +0 -0
- {ygrader-2.0.0 → ygrader-2.2.0}/ygrader.egg-info/dependency_links.txt +0 -0
- {ygrader-2.0.0 → ygrader-2.2.0}/ygrader.egg-info/requires.txt +0 -0
- {ygrader-2.0.0 → ygrader-2.2.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="2.
|
|
7
|
+
version="2.2.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",
|
|
@@ -44,7 +44,7 @@ class StudentDeductions:
|
|
|
44
44
|
|
|
45
45
|
def __init__(self, yaml_path: Optional[pathlib.Path] = None):
|
|
46
46
|
self.deductions_by_students = {}
|
|
47
|
-
self.
|
|
47
|
+
self.submit_time_by_students = {} # ISO format timestamp strings
|
|
48
48
|
self.deduction_types = {}
|
|
49
49
|
self.yaml_path = yaml_path
|
|
50
50
|
|
|
@@ -102,9 +102,9 @@ class StudentDeductions:
|
|
|
102
102
|
|
|
103
103
|
self.deductions_by_students[student_key] = deduction_items
|
|
104
104
|
|
|
105
|
-
# Load
|
|
106
|
-
if "
|
|
107
|
-
self.
|
|
105
|
+
# Load submit_time if present
|
|
106
|
+
if "submit_time" in entry:
|
|
107
|
+
self.submit_time_by_students[student_key] = entry["submit_time"]
|
|
108
108
|
|
|
109
109
|
def _write_yaml(self):
|
|
110
110
|
"""Write deduction types and student deductions to the YAML file.
|
|
@@ -134,9 +134,9 @@ class StudentDeductions:
|
|
|
134
134
|
)
|
|
135
135
|
data["deduction_types"] = deduction_list
|
|
136
136
|
|
|
137
|
-
# Write student deductions (include students with deductions OR
|
|
137
|
+
# Write student deductions (include students with deductions OR submit_time)
|
|
138
138
|
all_student_keys = set(self.deductions_by_students.keys()) | set(
|
|
139
|
-
self.
|
|
139
|
+
self.submit_time_by_students.keys()
|
|
140
140
|
)
|
|
141
141
|
# Sort student keys for consistent output ordering
|
|
142
142
|
sorted_student_keys = sorted(all_student_keys)
|
|
@@ -158,8 +158,8 @@ class StudentDeductions:
|
|
|
158
158
|
"net_ids": FlowList(student_key),
|
|
159
159
|
"deductions": FlowList(deduction_ids),
|
|
160
160
|
**(
|
|
161
|
-
{"
|
|
162
|
-
if student_key in self.
|
|
161
|
+
{"submit_time": self.submit_time_by_students[student_key]}
|
|
162
|
+
if student_key in self.submit_time_by_students
|
|
163
163
|
else {}
|
|
164
164
|
),
|
|
165
165
|
}
|
|
@@ -375,8 +375,8 @@ class StudentDeductions:
|
|
|
375
375
|
student_key = tuple(net_ids) if not isinstance(net_ids, tuple) else net_ids
|
|
376
376
|
if student_key in self.deductions_by_students:
|
|
377
377
|
del self.deductions_by_students[student_key]
|
|
378
|
-
if student_key in self.
|
|
379
|
-
del self.
|
|
378
|
+
if student_key in self.submit_time_by_students:
|
|
379
|
+
del self.submit_time_by_students[student_key]
|
|
380
380
|
self._save()
|
|
381
381
|
|
|
382
382
|
def ensure_student_in_file(self, net_ids: tuple):
|
|
@@ -405,31 +405,31 @@ class StudentDeductions:
|
|
|
405
405
|
student_key = tuple(net_ids) if not isinstance(net_ids, tuple) else net_ids
|
|
406
406
|
return student_key in self.deductions_by_students
|
|
407
407
|
|
|
408
|
-
def
|
|
409
|
-
"""Set the
|
|
408
|
+
def set_submit_time(self, net_ids: tuple, submit_time: Optional[str]):
|
|
409
|
+
"""Set the submission time for a student.
|
|
410
410
|
|
|
411
411
|
Args:
|
|
412
412
|
net_ids: Tuple of net_ids for the student.
|
|
413
|
-
|
|
413
|
+
submit_time: ISO format timestamp string, or None to remove.
|
|
414
414
|
"""
|
|
415
415
|
student_key = tuple(net_ids) if not isinstance(net_ids, tuple) else net_ids
|
|
416
|
-
if
|
|
417
|
-
self.
|
|
418
|
-
elif student_key in self.
|
|
419
|
-
del self.
|
|
416
|
+
if submit_time:
|
|
417
|
+
self.submit_time_by_students[student_key] = submit_time
|
|
418
|
+
elif student_key in self.submit_time_by_students:
|
|
419
|
+
del self.submit_time_by_students[student_key]
|
|
420
420
|
self._save()
|
|
421
421
|
|
|
422
|
-
def
|
|
423
|
-
"""Get the
|
|
422
|
+
def get_submit_time(self, net_ids: tuple) -> Optional[str]:
|
|
423
|
+
"""Get the submission time for a student.
|
|
424
424
|
|
|
425
425
|
Args:
|
|
426
426
|
net_ids: Tuple of net_ids for the student.
|
|
427
427
|
|
|
428
428
|
Returns:
|
|
429
|
-
|
|
429
|
+
ISO format timestamp string, or None if not set.
|
|
430
430
|
"""
|
|
431
431
|
student_key = tuple(net_ids) if not isinstance(net_ids, tuple) else net_ids
|
|
432
|
-
return self.
|
|
432
|
+
return self.submit_time_by_students.get(student_key)
|
|
433
433
|
|
|
434
434
|
def total_deductions(self, net_ids: Optional[tuple] = None) -> float:
|
|
435
435
|
"""Calculate the total deductions for a student or all students.
|
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
"""Module for generating student feedback files and grades CSV."""
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
import pathlib
|
|
5
|
+
import zipfile
|
|
6
|
+
from typing import Callable, Dict, Optional, Tuple
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import pandas
|
|
10
|
+
|
|
11
|
+
from .deductions import StudentDeductions
|
|
12
|
+
from .grading_item_config import LearningSuiteColumn
|
|
13
|
+
from .utils import warning, print_color, TermColors
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# Type alias for late penalty callback: (late_days, max_score, actual_score) -> new_score
|
|
17
|
+
LatePenaltyCallback = Callable[[int, float, float], float]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _calculate_late_days(
|
|
21
|
+
submit_time_str: Optional[str],
|
|
22
|
+
due_date: datetime.datetime,
|
|
23
|
+
) -> int:
|
|
24
|
+
"""Calculate the number of business days late from a submit time.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
submit_time_str: ISO format timestamp string of submission.
|
|
28
|
+
due_date: The effective due date for the student.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
Number of business days late (0 if on time or no submit time).
|
|
32
|
+
"""
|
|
33
|
+
if not submit_time_str:
|
|
34
|
+
return 0
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
submit_time = datetime.datetime.fromisoformat(submit_time_str)
|
|
38
|
+
except ValueError:
|
|
39
|
+
return 0
|
|
40
|
+
|
|
41
|
+
if submit_time <= due_date:
|
|
42
|
+
return 0
|
|
43
|
+
|
|
44
|
+
days_late = np.busday_count(due_date.date(), submit_time.date())
|
|
45
|
+
if days_late == 0:
|
|
46
|
+
days_late = 1 # Same day but after deadline
|
|
47
|
+
return int(days_late)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _get_student_key_and_max_late_days(
|
|
51
|
+
net_id: str,
|
|
52
|
+
item_deductions: Dict[str, StudentDeductions],
|
|
53
|
+
due_date: Optional[datetime.datetime] = None,
|
|
54
|
+
due_date_exceptions: Optional[Dict[str, datetime.datetime]] = None,
|
|
55
|
+
) -> tuple:
|
|
56
|
+
"""Find the student key and maximum late days across all items.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
net_id: The student's net ID.
|
|
60
|
+
item_deductions: Mapping from item name to StudentDeductions.
|
|
61
|
+
due_date: The default due date for the assignment.
|
|
62
|
+
due_date_exceptions: Mapping from net_id to exception due date.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
Tuple of (student_key or None, max_late_days).
|
|
66
|
+
"""
|
|
67
|
+
max_late_days = 0
|
|
68
|
+
found_student_key = None
|
|
69
|
+
|
|
70
|
+
if due_date_exceptions is None:
|
|
71
|
+
due_date_exceptions = {}
|
|
72
|
+
|
|
73
|
+
for deductions_obj in item_deductions.values():
|
|
74
|
+
if not deductions_obj:
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
# Find the student key
|
|
78
|
+
student_key = None
|
|
79
|
+
if (net_id,) in deductions_obj.deductions_by_students:
|
|
80
|
+
student_key = (net_id,)
|
|
81
|
+
elif (net_id,) in deductions_obj.submit_time_by_students:
|
|
82
|
+
student_key = (net_id,)
|
|
83
|
+
else:
|
|
84
|
+
# Check for multi-student keys containing this net_id
|
|
85
|
+
for key in set(deductions_obj.deductions_by_students.keys()) | set(
|
|
86
|
+
deductions_obj.submit_time_by_students.keys()
|
|
87
|
+
):
|
|
88
|
+
if net_id in key:
|
|
89
|
+
student_key = key
|
|
90
|
+
break
|
|
91
|
+
|
|
92
|
+
if student_key:
|
|
93
|
+
found_student_key = student_key
|
|
94
|
+
|
|
95
|
+
# Calculate late days from submit_time if we have a due date
|
|
96
|
+
if due_date is not None:
|
|
97
|
+
submit_time_str = deductions_obj.submit_time_by_students.get(
|
|
98
|
+
student_key
|
|
99
|
+
)
|
|
100
|
+
if submit_time_str:
|
|
101
|
+
# Calculate effective due date (using most generous exception for group)
|
|
102
|
+
effective_due_date = due_date
|
|
103
|
+
for member_net_id in student_key:
|
|
104
|
+
if member_net_id in due_date_exceptions:
|
|
105
|
+
effective_due_date = max(
|
|
106
|
+
effective_due_date, due_date_exceptions[member_net_id]
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
days_late = _calculate_late_days(
|
|
110
|
+
submit_time_str, effective_due_date
|
|
111
|
+
)
|
|
112
|
+
max_late_days = max(max_late_days, days_late)
|
|
113
|
+
|
|
114
|
+
return found_student_key, max_late_days
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _calculate_student_score(
|
|
118
|
+
net_id: str,
|
|
119
|
+
ls_column: LearningSuiteColumn,
|
|
120
|
+
item_deductions: Dict[str, StudentDeductions],
|
|
121
|
+
*,
|
|
122
|
+
late_penalty_callback: Optional[LatePenaltyCallback] = None,
|
|
123
|
+
warn_on_missing_callback: bool = True,
|
|
124
|
+
due_date: Optional[datetime.datetime] = None,
|
|
125
|
+
due_date_exceptions: Optional[Dict[str, datetime.datetime]] = None,
|
|
126
|
+
) -> Tuple[float, float, int]:
|
|
127
|
+
"""Calculate a student's final score.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
net_id: The student's net ID.
|
|
131
|
+
ls_column: The LearningSuiteColumn configuration.
|
|
132
|
+
item_deductions: Mapping from item name to StudentDeductions.
|
|
133
|
+
late_penalty_callback: Optional callback for late penalty.
|
|
134
|
+
warn_on_missing_callback: Whether to warn if late days found but no callback.
|
|
135
|
+
due_date: The default due date for the assignment.
|
|
136
|
+
due_date_exceptions: Mapping from net_id to exception due date.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
Tuple of (final_score, total_possible, max_late_days).
|
|
140
|
+
"""
|
|
141
|
+
total_possible = sum(item.points for item in ls_column.items)
|
|
142
|
+
total_deductions = 0.0
|
|
143
|
+
|
|
144
|
+
for item in ls_column.items:
|
|
145
|
+
deductions_obj = item_deductions.get(item.name)
|
|
146
|
+
if deductions_obj:
|
|
147
|
+
# Find the student's deductions
|
|
148
|
+
student_key = None
|
|
149
|
+
if (net_id,) in deductions_obj.deductions_by_students:
|
|
150
|
+
student_key = (net_id,)
|
|
151
|
+
else:
|
|
152
|
+
for key in deductions_obj.deductions_by_students.keys():
|
|
153
|
+
if net_id in key:
|
|
154
|
+
student_key = key
|
|
155
|
+
break
|
|
156
|
+
|
|
157
|
+
if student_key:
|
|
158
|
+
deductions = deductions_obj.deductions_by_students[student_key]
|
|
159
|
+
for deduction in deductions:
|
|
160
|
+
total_deductions += deduction.points
|
|
161
|
+
|
|
162
|
+
# Calculate score before late penalty
|
|
163
|
+
score = max(0, total_possible - total_deductions)
|
|
164
|
+
|
|
165
|
+
# Get max late days
|
|
166
|
+
_, max_late_days = _get_student_key_and_max_late_days(
|
|
167
|
+
net_id, item_deductions, due_date, due_date_exceptions
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
# Apply late penalty if applicable
|
|
171
|
+
if max_late_days > 0:
|
|
172
|
+
if late_penalty_callback:
|
|
173
|
+
score = max(0, late_penalty_callback(max_late_days, total_possible, score))
|
|
174
|
+
elif warn_on_missing_callback:
|
|
175
|
+
warning(
|
|
176
|
+
f"Student {net_id} has {max_late_days} late day(s) but no late penalty callback provided"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
return score, total_possible, max_late_days
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def assemble_grades(
|
|
183
|
+
yaml_path: pathlib.Path,
|
|
184
|
+
class_list_csv_path: pathlib.Path,
|
|
185
|
+
subitem_feedback_paths: Dict[str, pathlib.Path],
|
|
186
|
+
*,
|
|
187
|
+
output_zip_path: Optional[pathlib.Path] = None,
|
|
188
|
+
output_csv_path: Optional[pathlib.Path] = None,
|
|
189
|
+
late_penalty_callback: Optional[LatePenaltyCallback] = None,
|
|
190
|
+
due_date: Optional[datetime.datetime] = None,
|
|
191
|
+
due_date_exceptions: Optional[Dict[str, datetime.datetime]] = None,
|
|
192
|
+
) -> Tuple[Optional[pathlib.Path], Optional[pathlib.Path]]:
|
|
193
|
+
"""Generate feedback zip and/or grades CSV from deductions.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
yaml_path: Path to the YAML file that can be loaded by LearningSuiteColumn.
|
|
197
|
+
class_list_csv_path: Path to CSV file with class list (Net ID, First Name, Last Name).
|
|
198
|
+
subitem_feedback_paths: Mapping from subitem name to feedback YAML file path.
|
|
199
|
+
output_zip_path: Path for the output zip file. If None, no zip is generated.
|
|
200
|
+
output_csv_path: Path for the output CSV file. If None, no CSV is generated.
|
|
201
|
+
late_penalty_callback: Optional callback function that takes
|
|
202
|
+
(late_days, max_score, actual_score) and returns the adjusted score.
|
|
203
|
+
due_date: The default due date for the assignment. Required for late penalty.
|
|
204
|
+
due_date_exceptions: Mapping from net_id to exception due date.
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
Tuple of (feedback_zip_path or None, grades_csv_path or None).
|
|
208
|
+
"""
|
|
209
|
+
yaml_path = pathlib.Path(yaml_path)
|
|
210
|
+
ls_column = LearningSuiteColumn(yaml_path)
|
|
211
|
+
|
|
212
|
+
# Get the lab name from the YAML file's parent directory
|
|
213
|
+
lab_name = yaml_path.parent.name
|
|
214
|
+
|
|
215
|
+
# Load all student deductions for each subitem
|
|
216
|
+
subitem_deductions: Dict[str, StudentDeductions] = {}
|
|
217
|
+
for subitem in ls_column.items:
|
|
218
|
+
if subitem.name in subitem_feedback_paths:
|
|
219
|
+
feedback_path = subitem_feedback_paths[subitem.name]
|
|
220
|
+
if feedback_path.exists():
|
|
221
|
+
subitem_deductions[subitem.name] = StudentDeductions(feedback_path)
|
|
222
|
+
else:
|
|
223
|
+
subitem_deductions[subitem.name] = StudentDeductions()
|
|
224
|
+
else:
|
|
225
|
+
subitem_deductions[subitem.name] = StudentDeductions()
|
|
226
|
+
|
|
227
|
+
# Load class list
|
|
228
|
+
students_df = pandas.read_csv(class_list_csv_path)
|
|
229
|
+
|
|
230
|
+
# Prepare for CSV output
|
|
231
|
+
grades_data = []
|
|
232
|
+
|
|
233
|
+
def process_students(zip_file: Optional[zipfile.ZipFile]) -> None:
|
|
234
|
+
"""Process all students, adding to grades_data and optionally writing to zip."""
|
|
235
|
+
for _, student_row in students_df.iterrows():
|
|
236
|
+
first_name = str(student_row["First Name"]).strip()
|
|
237
|
+
last_name = str(student_row["Last Name"]).strip()
|
|
238
|
+
net_id = str(student_row["Net ID"]).strip()
|
|
239
|
+
|
|
240
|
+
# Calculate score before late penalty
|
|
241
|
+
score_before_late, total_possible, max_late_days = _calculate_student_score(
|
|
242
|
+
net_id=net_id,
|
|
243
|
+
ls_column=ls_column,
|
|
244
|
+
item_deductions=subitem_deductions,
|
|
245
|
+
late_penalty_callback=None, # Don't apply late penalty yet
|
|
246
|
+
warn_on_missing_callback=False,
|
|
247
|
+
due_date=due_date,
|
|
248
|
+
due_date_exceptions=due_date_exceptions,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
# Apply late penalty if applicable
|
|
252
|
+
final_score = score_before_late
|
|
253
|
+
if max_late_days > 0 and late_penalty_callback:
|
|
254
|
+
final_score = max(
|
|
255
|
+
0,
|
|
256
|
+
late_penalty_callback(
|
|
257
|
+
max_late_days, total_possible, score_before_late
|
|
258
|
+
),
|
|
259
|
+
)
|
|
260
|
+
print_color(
|
|
261
|
+
TermColors.YELLOW,
|
|
262
|
+
f"Late: {net_id} ({max_late_days} day{'s' if max_late_days != 1 else ''}): "
|
|
263
|
+
f"{score_before_late:.1f} -> {final_score:.1f}",
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
# Add to grades data
|
|
267
|
+
if output_csv_path:
|
|
268
|
+
grades_data.append(
|
|
269
|
+
{"Net ID": net_id, ls_column.csv_col_name: final_score}
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
# Generate feedback file
|
|
273
|
+
if zip_file:
|
|
274
|
+
feedback_content = _generate_student_feedback(
|
|
275
|
+
student_row=student_row,
|
|
276
|
+
ls_column=ls_column,
|
|
277
|
+
subitem_deductions=subitem_deductions,
|
|
278
|
+
late_penalty_callback=late_penalty_callback,
|
|
279
|
+
due_date=due_date,
|
|
280
|
+
due_date_exceptions=due_date_exceptions,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
filename = (
|
|
284
|
+
first_name
|
|
285
|
+
+ "_"
|
|
286
|
+
+ last_name
|
|
287
|
+
+ "_"
|
|
288
|
+
+ net_id
|
|
289
|
+
+ "_feedback-"
|
|
290
|
+
+ lab_name
|
|
291
|
+
+ ".txt"
|
|
292
|
+
)
|
|
293
|
+
zip_file.writestr(filename, feedback_content)
|
|
294
|
+
|
|
295
|
+
# Process students with or without zip file
|
|
296
|
+
if output_zip_path:
|
|
297
|
+
with zipfile.ZipFile(output_zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
298
|
+
process_students(zf)
|
|
299
|
+
else:
|
|
300
|
+
process_students(None)
|
|
301
|
+
|
|
302
|
+
# Write CSV (sorted by Net ID for easier git diffs)
|
|
303
|
+
if output_csv_path and grades_data:
|
|
304
|
+
grades_df = pandas.DataFrame(grades_data)
|
|
305
|
+
grades_df = grades_df.sort_values("Net ID")
|
|
306
|
+
grades_df.to_csv(output_csv_path, index=False)
|
|
307
|
+
|
|
308
|
+
return (output_zip_path, output_csv_path)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _generate_student_feedback(
|
|
312
|
+
student_row: pandas.Series,
|
|
313
|
+
ls_column: LearningSuiteColumn,
|
|
314
|
+
subitem_deductions: Dict[str, StudentDeductions],
|
|
315
|
+
*,
|
|
316
|
+
late_penalty_callback: Optional[LatePenaltyCallback] = None,
|
|
317
|
+
due_date: Optional[datetime.datetime] = None,
|
|
318
|
+
due_date_exceptions: Optional[Dict[str, datetime.datetime]] = None,
|
|
319
|
+
) -> str:
|
|
320
|
+
"""Generate the feedback text content for a single student.
|
|
321
|
+
|
|
322
|
+
Args:
|
|
323
|
+
student_row: A row from the student DataFrame.
|
|
324
|
+
ls_column: The LearningSuiteColumn object.
|
|
325
|
+
subitem_deductions: Mapping from subitem name to StudentDeductions.
|
|
326
|
+
late_penalty_callback: Optional callback for calculating late penalty.
|
|
327
|
+
due_date: The default due date for the assignment.
|
|
328
|
+
due_date_exceptions: Mapping from net_id to exception due date.
|
|
329
|
+
|
|
330
|
+
Returns:
|
|
331
|
+
The formatted feedback text.
|
|
332
|
+
"""
|
|
333
|
+
net_id = str(student_row["Net ID"]).strip()
|
|
334
|
+
first_name = str(student_row["First Name"]).strip()
|
|
335
|
+
last_name = str(student_row["Last Name"]).strip()
|
|
336
|
+
|
|
337
|
+
lines = []
|
|
338
|
+
lines.append(f"Feedback for {first_name} {last_name} ({net_id})")
|
|
339
|
+
lines.append("=" * 60)
|
|
340
|
+
lines.append("")
|
|
341
|
+
|
|
342
|
+
total_points_possible = 0
|
|
343
|
+
total_points_deducted = 0
|
|
344
|
+
|
|
345
|
+
# Column widths for formatting
|
|
346
|
+
# Total width is 60 characters to match separator lines
|
|
347
|
+
# Item name column + score column (e.g., "10.0 / 10.0")
|
|
348
|
+
item_col_width = 42
|
|
349
|
+
score_col_width = 17
|
|
350
|
+
# For deduction lines: " - " prefix (4 chars) + message + points
|
|
351
|
+
deduction_prefix = " - "
|
|
352
|
+
deduction_msg_width = 38
|
|
353
|
+
deduction_pts_width = 17
|
|
354
|
+
|
|
355
|
+
for item in ls_column.items:
|
|
356
|
+
subitem_points_possible = item.points
|
|
357
|
+
total_points_possible += subitem_points_possible
|
|
358
|
+
|
|
359
|
+
subitem_points_deducted = 0
|
|
360
|
+
item_deductions = []
|
|
361
|
+
|
|
362
|
+
# Get deductions for this student in this item
|
|
363
|
+
student_deductions_obj = subitem_deductions.get(item.name)
|
|
364
|
+
if student_deductions_obj:
|
|
365
|
+
# Find the student's deductions (try single net_id first, then tuple)
|
|
366
|
+
student_key = None
|
|
367
|
+
if (net_id,) in student_deductions_obj.deductions_by_students:
|
|
368
|
+
student_key = (net_id,)
|
|
369
|
+
else:
|
|
370
|
+
# Check for multi-student keys containing this net_id
|
|
371
|
+
for key in student_deductions_obj.deductions_by_students.keys():
|
|
372
|
+
if net_id in key:
|
|
373
|
+
student_key = key
|
|
374
|
+
break
|
|
375
|
+
|
|
376
|
+
if student_key:
|
|
377
|
+
deductions = student_deductions_obj.deductions_by_students[student_key]
|
|
378
|
+
for deduction in deductions:
|
|
379
|
+
item_deductions.append((deduction.message, deduction.points))
|
|
380
|
+
subitem_points_deducted += deduction.points
|
|
381
|
+
|
|
382
|
+
# Calculate item score
|
|
383
|
+
subitem_score = max(0, subitem_points_possible - subitem_points_deducted)
|
|
384
|
+
score_str = f"{subitem_score:.1f} / {subitem_points_possible:.1f}"
|
|
385
|
+
|
|
386
|
+
# Item line with score
|
|
387
|
+
item_name_with_colon = f"{item.name}:"
|
|
388
|
+
lines.append(
|
|
389
|
+
f"{item_name_with_colon:<{item_col_width}} {score_str:>{score_col_width}}"
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
# Deduction lines (indented)
|
|
393
|
+
for msg, pts in item_deductions:
|
|
394
|
+
# Wrap long messages
|
|
395
|
+
wrapped = _wrap_text(msg, deduction_msg_width)
|
|
396
|
+
for i, line_text in enumerate(wrapped):
|
|
397
|
+
if i == 0:
|
|
398
|
+
lines.append(
|
|
399
|
+
f"{deduction_prefix}{line_text:<{deduction_msg_width}} {-pts:>{deduction_pts_width}.1f}"
|
|
400
|
+
)
|
|
401
|
+
else:
|
|
402
|
+
# Continuation lines - no points
|
|
403
|
+
lines.append(f"{deduction_prefix}{line_text}")
|
|
404
|
+
|
|
405
|
+
total_points_deducted += subitem_points_deducted
|
|
406
|
+
|
|
407
|
+
# Calculate score before late penalty (clamped to 0)
|
|
408
|
+
score_before_late = max(0, total_points_possible - total_points_deducted)
|
|
409
|
+
|
|
410
|
+
# Get max late days for this student
|
|
411
|
+
_, max_late_days = _get_student_key_and_max_late_days(
|
|
412
|
+
net_id, subitem_deductions, due_date, due_date_exceptions
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
# Late penalty section
|
|
416
|
+
lines.append("")
|
|
417
|
+
lines.append("=" * 60)
|
|
418
|
+
if max_late_days > 0 and late_penalty_callback:
|
|
419
|
+
final_score = late_penalty_callback(
|
|
420
|
+
max_late_days, total_points_possible, score_before_late
|
|
421
|
+
)
|
|
422
|
+
# Ensure final score is not negative
|
|
423
|
+
final_score = max(0, final_score)
|
|
424
|
+
late_penalty_points = score_before_late - final_score
|
|
425
|
+
late_label = (
|
|
426
|
+
f"Late Penalty ({max_late_days} day{'s' if max_late_days != 1 else ''}):"
|
|
427
|
+
)
|
|
428
|
+
lines.append(
|
|
429
|
+
f"{late_label:<{item_col_width}} {-late_penalty_points:>{score_col_width}.1f}"
|
|
430
|
+
)
|
|
431
|
+
else:
|
|
432
|
+
final_score = score_before_late
|
|
433
|
+
lines.append(
|
|
434
|
+
f"{'Late Penalty:':<{item_col_width}} {'On Time':>{score_col_width}}"
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
# Total score section
|
|
438
|
+
total_score_str = f"{final_score:.1f} / {total_points_possible:.1f}"
|
|
439
|
+
lines.append(
|
|
440
|
+
f"{'TOTAL SCORE:':<{item_col_width}} {total_score_str:>{score_col_width}}"
|
|
441
|
+
)
|
|
442
|
+
lines.append("=" * 60)
|
|
443
|
+
|
|
444
|
+
return "\n".join(lines)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _wrap_text(text: str, width: int) -> list:
|
|
448
|
+
"""Wrap text to fit within a given width.
|
|
449
|
+
|
|
450
|
+
Args:
|
|
451
|
+
text: The text to wrap.
|
|
452
|
+
width: Maximum width for each line.
|
|
453
|
+
|
|
454
|
+
Returns:
|
|
455
|
+
List of wrapped lines.
|
|
456
|
+
"""
|
|
457
|
+
if len(text) <= width:
|
|
458
|
+
return [text]
|
|
459
|
+
|
|
460
|
+
words = text.split()
|
|
461
|
+
lines = []
|
|
462
|
+
current_line = ""
|
|
463
|
+
|
|
464
|
+
for word in words:
|
|
465
|
+
if not current_line:
|
|
466
|
+
current_line = word
|
|
467
|
+
elif len(current_line) + 1 + len(word) <= width:
|
|
468
|
+
current_line += " " + word
|
|
469
|
+
else:
|
|
470
|
+
lines.append(current_line)
|
|
471
|
+
current_line = word
|
|
472
|
+
|
|
473
|
+
if current_line:
|
|
474
|
+
lines.append(current_line)
|
|
475
|
+
|
|
476
|
+
return lines if lines else [""]
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
"""Main ygrader module"""
|
|
2
2
|
|
|
3
|
-
import datetime as dt
|
|
4
3
|
import enum
|
|
5
4
|
import inspect
|
|
6
5
|
import os
|
|
@@ -13,7 +12,6 @@ from collections import defaultdict
|
|
|
13
12
|
from typing import Callable
|
|
14
13
|
|
|
15
14
|
import pandas
|
|
16
|
-
import yaml
|
|
17
15
|
|
|
18
16
|
from . import grades_csv, student_repos, utils
|
|
19
17
|
from .grading_item import GradeItem
|
|
@@ -101,7 +99,6 @@ class Grader:
|
|
|
101
99
|
self.github_https = None
|
|
102
100
|
self.groups_csv_path = None
|
|
103
101
|
self.groups_csv_col_name = None
|
|
104
|
-
self.due_date_exceptions = {}
|
|
105
102
|
self.set_other_options()
|
|
106
103
|
|
|
107
104
|
def add_item_to_grade(
|
|
@@ -335,8 +332,6 @@ class Grader:
|
|
|
335
332
|
dry_run_first=False,
|
|
336
333
|
dry_run_all=False,
|
|
337
334
|
workflow_hash=None,
|
|
338
|
-
due_date=None,
|
|
339
|
-
due_date_exceptions_path=None,
|
|
340
335
|
):
|
|
341
336
|
"""
|
|
342
337
|
This can be used to set other options for the grader.
|
|
@@ -376,15 +371,6 @@ class Grader:
|
|
|
376
371
|
(Optional) Expected hash of the GitHub workflow file. If provided, the workflow file will be verified
|
|
377
372
|
before grading each student. If the hash doesn't match, a warning will be displayed indicating
|
|
378
373
|
the student may have modified the workflow system.
|
|
379
|
-
due_date: datetime.datetime
|
|
380
|
-
(Optional) Due date for the assignment. If provided, the submission date will be compared to this
|
|
381
|
-
and late days will be calculated and displayed.
|
|
382
|
-
due_date_exceptions_path: str
|
|
383
|
-
(Optional) Path to a YAML file containing per-student due date exceptions. The file should be
|
|
384
|
-
a simple dictionary mapping net_ids to deadline strings in "YYYY-MM-DD HH:MM:SS" format.
|
|
385
|
-
Example:
|
|
386
|
-
"student1": "2025-01-15 23:59:59"
|
|
387
|
-
"student2": "2025-01-17 23:59:59"
|
|
388
374
|
"""
|
|
389
375
|
self.format_code = format_code
|
|
390
376
|
self.build_only = build_only
|
|
@@ -392,9 +378,6 @@ class Grader:
|
|
|
392
378
|
self.allow_rebuild = allow_rebuild
|
|
393
379
|
self.allow_rerun = allow_rerun
|
|
394
380
|
self.workflow_hash = workflow_hash
|
|
395
|
-
self.due_date = due_date
|
|
396
|
-
self.due_date_exceptions = {}
|
|
397
|
-
self.due_date_exceptions_path = due_date_exceptions_path
|
|
398
381
|
if prep_fcn and not isinstance(prep_fcn, Callable):
|
|
399
382
|
error("The 'prep_fcn' argument must provide a callable function pointer")
|
|
400
383
|
self.prep_fcn = prep_fcn
|
|
@@ -424,36 +407,6 @@ class Grader:
|
|
|
424
407
|
+ "set_submission_system_learning_suite() or set_submission_system_github()."
|
|
425
408
|
)
|
|
426
409
|
|
|
427
|
-
def _load_due_date_exceptions(self):
|
|
428
|
-
"""Load due date exceptions from YAML file (simple net_id: deadline format)"""
|
|
429
|
-
|
|
430
|
-
self.due_date_exceptions = {}
|
|
431
|
-
if not self.due_date_exceptions_path:
|
|
432
|
-
return
|
|
433
|
-
|
|
434
|
-
try:
|
|
435
|
-
with open(self.due_date_exceptions_path, "r", encoding="utf-8") as f:
|
|
436
|
-
exceptions_raw = yaml.safe_load(f)
|
|
437
|
-
except (IOError, yaml.YAMLError) as e:
|
|
438
|
-
print_color(
|
|
439
|
-
TermColors.YELLOW, f"Warning: Could not load exceptions file: {e}"
|
|
440
|
-
)
|
|
441
|
-
return
|
|
442
|
-
|
|
443
|
-
if not exceptions_raw or not isinstance(exceptions_raw, dict):
|
|
444
|
-
return
|
|
445
|
-
|
|
446
|
-
for net_id, deadline_str in exceptions_raw.items():
|
|
447
|
-
try:
|
|
448
|
-
self.due_date_exceptions[net_id] = dt.datetime.strptime(
|
|
449
|
-
deadline_str, "%Y-%m-%d %H:%M:%S"
|
|
450
|
-
)
|
|
451
|
-
except ValueError as e:
|
|
452
|
-
print_color(
|
|
453
|
-
TermColors.YELLOW,
|
|
454
|
-
f"Warning: Could not parse deadline for {net_id}: {e}",
|
|
455
|
-
)
|
|
456
|
-
|
|
457
410
|
def _get_all_csv_cols_to_grade(self):
|
|
458
411
|
"""Collect all columns that will be graded into a single list.
|
|
459
412
|
|
|
@@ -465,7 +418,6 @@ class Grader:
|
|
|
465
418
|
"""Call this to start (or resume) the grading process"""
|
|
466
419
|
|
|
467
420
|
self._validate_config()
|
|
468
|
-
self._load_due_date_exceptions()
|
|
469
421
|
|
|
470
422
|
# Print starting message
|
|
471
423
|
print_color(TermColors.BLUE, "Running grader for", self.lab_name)
|
|
@@ -3,8 +3,6 @@
|
|
|
3
3
|
import datetime
|
|
4
4
|
import sys
|
|
5
5
|
|
|
6
|
-
import numpy as np
|
|
7
|
-
|
|
8
6
|
from .utils import (
|
|
9
7
|
CallbackFailed,
|
|
10
8
|
TermColors,
|
|
@@ -149,10 +147,10 @@ class GradeItem:
|
|
|
149
147
|
print_color(TermColors.RED, "=" * 70)
|
|
150
148
|
print("")
|
|
151
149
|
|
|
152
|
-
# Display submission date if available
|
|
150
|
+
# Display submission date if available and store for later late calculation
|
|
153
151
|
student_code_path = callback_args.get("student_code_path")
|
|
154
|
-
#
|
|
155
|
-
|
|
152
|
+
# Store submission time but don't save yet - will be saved only after successful grading
|
|
153
|
+
pending_submit_time = None
|
|
156
154
|
if student_code_path:
|
|
157
155
|
submission_date_path = student_code_path / ".commitdate"
|
|
158
156
|
if submission_date_path.is_file():
|
|
@@ -166,43 +164,8 @@ class GradeItem:
|
|
|
166
164
|
TermColors.BLUE,
|
|
167
165
|
f"Submitted: {submission_time.strftime('%Y-%m-%d %H:%M:%S')}",
|
|
168
166
|
)
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
if self.grader.due_date is not None:
|
|
172
|
-
# Check for student-specific due date exception
|
|
173
|
-
# Use the latest (most generous) exception if multiple group members have them
|
|
174
|
-
effective_due_date = self.grader.due_date
|
|
175
|
-
for net_id in net_ids:
|
|
176
|
-
if net_id in self.grader.due_date_exceptions:
|
|
177
|
-
exception_date = self.grader.due_date_exceptions[
|
|
178
|
-
net_id
|
|
179
|
-
]
|
|
180
|
-
effective_due_date = max(
|
|
181
|
-
effective_due_date, exception_date
|
|
182
|
-
)
|
|
183
|
-
|
|
184
|
-
has_exception = effective_due_date != self.grader.due_date
|
|
185
|
-
if has_exception:
|
|
186
|
-
print_color(
|
|
187
|
-
TermColors.YELLOW,
|
|
188
|
-
f"Exception due date: {effective_due_date.strftime('%Y-%m-%d %H:%M:%S')}",
|
|
189
|
-
)
|
|
190
|
-
|
|
191
|
-
if submission_time <= effective_due_date:
|
|
192
|
-
print_color(TermColors.GREEN, "Status: ON TIME")
|
|
193
|
-
pending_days_late = 0
|
|
194
|
-
else:
|
|
195
|
-
days_late = np.busday_count(
|
|
196
|
-
effective_due_date.date(),
|
|
197
|
-
submission_time.date(),
|
|
198
|
-
)
|
|
199
|
-
if days_late == 0:
|
|
200
|
-
days_late = 1 # Same day but after deadline
|
|
201
|
-
print_color(
|
|
202
|
-
TermColors.RED,
|
|
203
|
-
f"Status: LATE ({days_late} business day(s))",
|
|
204
|
-
)
|
|
205
|
-
pending_days_late = int(days_late)
|
|
167
|
+
# Store as ISO format for later late calculation
|
|
168
|
+
pending_submit_time = submission_time.isoformat()
|
|
206
169
|
except (ValueError, IOError) as e:
|
|
207
170
|
print_color(
|
|
208
171
|
TermColors.YELLOW,
|
|
@@ -230,10 +193,10 @@ class GradeItem:
|
|
|
230
193
|
TermColors.BLUE,
|
|
231
194
|
f"Applied deduction: {deduction_desc} (-{deduction_points})",
|
|
232
195
|
)
|
|
233
|
-
# Save
|
|
234
|
-
if
|
|
235
|
-
self.student_deductions.
|
|
236
|
-
tuple(net_ids),
|
|
196
|
+
# Save submit_time now that grading succeeded
|
|
197
|
+
if pending_submit_time is not None:
|
|
198
|
+
self.student_deductions.set_submit_time(
|
|
199
|
+
tuple(net_ids), pending_submit_time
|
|
237
200
|
)
|
|
238
201
|
# Ensure student is in the deductions file
|
|
239
202
|
self.student_deductions.ensure_student_in_file(tuple(net_ids))
|
|
@@ -270,10 +233,10 @@ class GradeItem:
|
|
|
270
233
|
build = False
|
|
271
234
|
continue
|
|
272
235
|
|
|
273
|
-
# Record score - save
|
|
236
|
+
# Record score - save submit_time and ensure the student is in the deductions file
|
|
274
237
|
# (even if they have no deductions, to indicate they were graded)
|
|
275
|
-
if
|
|
276
|
-
self.student_deductions.
|
|
238
|
+
if pending_submit_time is not None:
|
|
239
|
+
self.student_deductions.set_submit_time(tuple(net_ids), pending_submit_time)
|
|
277
240
|
self.student_deductions.ensure_student_in_file(tuple(net_ids))
|
|
278
241
|
break
|
|
279
242
|
|
|
@@ -19,7 +19,7 @@ class LearningSuiteColumn:
|
|
|
19
19
|
self.csv_col_name = None
|
|
20
20
|
self.other_data = {}
|
|
21
21
|
|
|
22
|
-
#
|
|
22
|
+
# Make sure the YAML file exists
|
|
23
23
|
if yaml_path.suffix != ".yaml":
|
|
24
24
|
raise LearningSuiteColumnParseError(
|
|
25
25
|
"The item_yaml_path must point to a .yaml file."
|
|
@@ -28,10 +28,6 @@ class LearningSuiteColumn:
|
|
|
28
28
|
raise LearningSuiteColumnParseError(
|
|
29
29
|
f"The specified YAML file does not exist: {yaml_path}"
|
|
30
30
|
)
|
|
31
|
-
if yaml_path.parent.name != yaml_path.stem:
|
|
32
|
-
raise LearningSuiteColumnParseError(
|
|
33
|
-
f"The YAML file must be located in a directory of the same name. Currently {yaml_path} is in {yaml_path.parent}"
|
|
34
|
-
)
|
|
35
31
|
|
|
36
32
|
with yaml_path.open("r") as f:
|
|
37
33
|
data = yaml.safe_load(f)
|
|
@@ -1,218 +0,0 @@
|
|
|
1
|
-
"""Module for generating student feedback files."""
|
|
2
|
-
|
|
3
|
-
import pathlib
|
|
4
|
-
import zipfile
|
|
5
|
-
from typing import Dict
|
|
6
|
-
|
|
7
|
-
import pandas
|
|
8
|
-
|
|
9
|
-
from .deductions import StudentDeductions
|
|
10
|
-
from .grading_item_config import LearningSuiteColumn
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
def generate_feedback_zip(
|
|
14
|
-
yaml_path: pathlib.Path,
|
|
15
|
-
class_list_csv_path: pathlib.Path,
|
|
16
|
-
subitem_feedback_paths: Dict[str, pathlib.Path],
|
|
17
|
-
output_zip_path: pathlib.Path = None,
|
|
18
|
-
) -> pathlib.Path:
|
|
19
|
-
"""Generate a zip file containing feedback files for each student.
|
|
20
|
-
|
|
21
|
-
Args:
|
|
22
|
-
yaml_path: Path to the YAML file that can be loaded by LearningSuiteColumn.
|
|
23
|
-
class_list_csv_path: Path to CSV file with class list (Net ID, First Name, Last Name).
|
|
24
|
-
subitem_feedback_paths: Mapping from subitem name to feedback YAML file path.
|
|
25
|
-
output_zip_path: Path for the output zip file. If None, defaults to
|
|
26
|
-
<yaml_dir>/feedback.zip.
|
|
27
|
-
|
|
28
|
-
Returns:
|
|
29
|
-
Path to the generated zip file.
|
|
30
|
-
"""
|
|
31
|
-
yaml_path = pathlib.Path(yaml_path)
|
|
32
|
-
ls_column = LearningSuiteColumn(yaml_path)
|
|
33
|
-
|
|
34
|
-
# Get the lab name from the YAML file's parent directory
|
|
35
|
-
lab_name = yaml_path.stem
|
|
36
|
-
|
|
37
|
-
# Default output path
|
|
38
|
-
if output_zip_path is None:
|
|
39
|
-
output_zip_path = yaml_path.parent / "feedback.zip"
|
|
40
|
-
|
|
41
|
-
# Load all student deductions for each subitem
|
|
42
|
-
subitem_deductions: Dict[str, StudentDeductions] = {}
|
|
43
|
-
for subitem in ls_column.items:
|
|
44
|
-
if subitem.name in subitem_feedback_paths:
|
|
45
|
-
feedback_path = subitem_feedback_paths[subitem.name]
|
|
46
|
-
if feedback_path.exists():
|
|
47
|
-
subitem_deductions[subitem.name] = StudentDeductions(feedback_path)
|
|
48
|
-
else:
|
|
49
|
-
subitem_deductions[subitem.name] = StudentDeductions()
|
|
50
|
-
else:
|
|
51
|
-
subitem_deductions[subitem.name] = StudentDeductions()
|
|
52
|
-
|
|
53
|
-
# Load class list from provided CSV
|
|
54
|
-
students_df = pandas.read_csv(class_list_csv_path)
|
|
55
|
-
|
|
56
|
-
# Create the zip file
|
|
57
|
-
with zipfile.ZipFile(output_zip_path, "w", zipfile.ZIP_DEFLATED) as zip_file:
|
|
58
|
-
for _, student_row in students_df.iterrows():
|
|
59
|
-
first_name = str(student_row["First Name"]).strip()
|
|
60
|
-
last_name = str(student_row["Last Name"]).strip()
|
|
61
|
-
net_id = str(student_row["Net ID"]).strip()
|
|
62
|
-
|
|
63
|
-
# Generate feedback content for this student
|
|
64
|
-
feedback_content = _generate_student_feedback(
|
|
65
|
-
student_row=student_row,
|
|
66
|
-
ls_column=ls_column,
|
|
67
|
-
subitem_deductions=subitem_deductions,
|
|
68
|
-
)
|
|
69
|
-
|
|
70
|
-
# Generate filename
|
|
71
|
-
filename = (
|
|
72
|
-
first_name
|
|
73
|
-
+ "_"
|
|
74
|
-
+ last_name
|
|
75
|
-
+ "_"
|
|
76
|
-
+ net_id
|
|
77
|
-
+ "_feedback-"
|
|
78
|
-
+ lab_name
|
|
79
|
-
+ ".txt"
|
|
80
|
-
)
|
|
81
|
-
|
|
82
|
-
# Add to zip file
|
|
83
|
-
zip_file.writestr(filename, feedback_content)
|
|
84
|
-
|
|
85
|
-
return output_zip_path
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
def _generate_student_feedback(
|
|
89
|
-
student_row: pandas.Series,
|
|
90
|
-
ls_column: LearningSuiteColumn,
|
|
91
|
-
subitem_deductions: Dict[str, StudentDeductions],
|
|
92
|
-
) -> str:
|
|
93
|
-
"""Generate the feedback text content for a single student.
|
|
94
|
-
|
|
95
|
-
Args:
|
|
96
|
-
student_row: A row from the student DataFrame.
|
|
97
|
-
ls_column: The LearningSuiteColumn object.
|
|
98
|
-
subitem_deductions: Mapping from subitem name to StudentDeductions.
|
|
99
|
-
|
|
100
|
-
Returns:
|
|
101
|
-
The formatted feedback text.
|
|
102
|
-
"""
|
|
103
|
-
net_id = str(student_row["Net ID"]).strip()
|
|
104
|
-
first_name = str(student_row["First Name"]).strip()
|
|
105
|
-
last_name = str(student_row["Last Name"]).strip()
|
|
106
|
-
|
|
107
|
-
lines = []
|
|
108
|
-
lines.append(f"Feedback for {first_name} {last_name} ({net_id})")
|
|
109
|
-
lines.append("=" * 60)
|
|
110
|
-
lines.append("")
|
|
111
|
-
|
|
112
|
-
total_points_possible = 0
|
|
113
|
-
total_points_deducted = 0
|
|
114
|
-
|
|
115
|
-
# Column widths for formatting
|
|
116
|
-
feedback_col_width = 45
|
|
117
|
-
points_col_width = 15
|
|
118
|
-
|
|
119
|
-
for item in ls_column.items:
|
|
120
|
-
subitem_points_possible = item.points
|
|
121
|
-
total_points_possible += subitem_points_possible
|
|
122
|
-
|
|
123
|
-
lines.append(f"{item.name} ({subitem_points_possible} points)")
|
|
124
|
-
lines.append("-" * 60)
|
|
125
|
-
|
|
126
|
-
# Header row
|
|
127
|
-
header = f"{'Feedback':<{feedback_col_width}} {'Points Deducted':>{points_col_width}}"
|
|
128
|
-
lines.append(header)
|
|
129
|
-
lines.append("-" * 60)
|
|
130
|
-
|
|
131
|
-
subitem_points_deducted = 0
|
|
132
|
-
|
|
133
|
-
# Get deductions for this student in this item
|
|
134
|
-
student_deductions_obj = subitem_deductions.get(item.name)
|
|
135
|
-
if student_deductions_obj:
|
|
136
|
-
# Find the student's deductions (try single net_id first, then tuple)
|
|
137
|
-
student_key = None
|
|
138
|
-
if (net_id,) in student_deductions_obj.deductions_by_students:
|
|
139
|
-
student_key = (net_id,)
|
|
140
|
-
else:
|
|
141
|
-
# Check for multi-student keys containing this net_id
|
|
142
|
-
for key in student_deductions_obj.deductions_by_students.keys():
|
|
143
|
-
if net_id in key:
|
|
144
|
-
student_key = key
|
|
145
|
-
break
|
|
146
|
-
|
|
147
|
-
if student_key:
|
|
148
|
-
deductions = student_deductions_obj.deductions_by_students[student_key]
|
|
149
|
-
for deduction in deductions:
|
|
150
|
-
feedback_text = deduction.message
|
|
151
|
-
points = deduction.points
|
|
152
|
-
|
|
153
|
-
# Wrap long feedback text
|
|
154
|
-
wrapped_lines = _wrap_text(feedback_text, feedback_col_width)
|
|
155
|
-
for i, line_text in enumerate(wrapped_lines):
|
|
156
|
-
if i == 0:
|
|
157
|
-
row = f"{line_text:<{feedback_col_width}} {points:>{points_col_width}.1f}"
|
|
158
|
-
else:
|
|
159
|
-
row = f"{line_text:<{feedback_col_width}} {'':{points_col_width}}"
|
|
160
|
-
lines.append(row)
|
|
161
|
-
|
|
162
|
-
subitem_points_deducted += points
|
|
163
|
-
|
|
164
|
-
if subitem_points_deducted == 0:
|
|
165
|
-
lines.append(
|
|
166
|
-
f"{'No deductions':<{feedback_col_width}} {'0.0':>{points_col_width}}"
|
|
167
|
-
)
|
|
168
|
-
|
|
169
|
-
lines.append("-" * 60)
|
|
170
|
-
subitem_score = subitem_points_possible - subitem_points_deducted
|
|
171
|
-
lines.append(
|
|
172
|
-
f"{'Subitem Total:':<{feedback_col_width}} {subitem_score:>{points_col_width}.1f} / {subitem_points_possible:.1f}"
|
|
173
|
-
)
|
|
174
|
-
lines.append("")
|
|
175
|
-
|
|
176
|
-
total_points_deducted += subitem_points_deducted
|
|
177
|
-
|
|
178
|
-
# Total score section
|
|
179
|
-
lines.append("=" * 60)
|
|
180
|
-
total_score = total_points_possible - total_points_deducted
|
|
181
|
-
lines.append(
|
|
182
|
-
f"{'TOTAL SCORE:':<{feedback_col_width}} {total_score:>{points_col_width}.1f} / {total_points_possible:.1f}"
|
|
183
|
-
)
|
|
184
|
-
lines.append("=" * 60)
|
|
185
|
-
|
|
186
|
-
return "\n".join(lines)
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
def _wrap_text(text: str, width: int) -> list:
|
|
190
|
-
"""Wrap text to fit within a given width.
|
|
191
|
-
|
|
192
|
-
Args:
|
|
193
|
-
text: The text to wrap.
|
|
194
|
-
width: Maximum width for each line.
|
|
195
|
-
|
|
196
|
-
Returns:
|
|
197
|
-
List of wrapped lines.
|
|
198
|
-
"""
|
|
199
|
-
if len(text) <= width:
|
|
200
|
-
return [text]
|
|
201
|
-
|
|
202
|
-
words = text.split()
|
|
203
|
-
lines = []
|
|
204
|
-
current_line = ""
|
|
205
|
-
|
|
206
|
-
for word in words:
|
|
207
|
-
if not current_line:
|
|
208
|
-
current_line = word
|
|
209
|
-
elif len(current_line) + 1 + len(word) <= width:
|
|
210
|
-
current_line += " " + word
|
|
211
|
-
else:
|
|
212
|
-
lines.append(current_line)
|
|
213
|
-
current_line = word
|
|
214
|
-
|
|
215
|
-
if current_line:
|
|
216
|
-
lines.append(current_line)
|
|
217
|
-
|
|
218
|
-
return lines if lines else [""]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|