ygrader 2.0.0__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-2.0.0/ygrader.egg-info → ygrader-2.1.0}/PKG-INFO +1 -1
- {ygrader-2.0.0 → ygrader-2.1.0}/setup.py +1 -1
- {ygrader-2.0.0 → ygrader-2.1.0}/ygrader/__init__.py +1 -1
- ygrader-2.1.0/ygrader/feedback.py +383 -0
- {ygrader-2.0.0 → ygrader-2.1.0}/ygrader/grading_item_config.py +1 -5
- {ygrader-2.0.0 → ygrader-2.1.0/ygrader.egg-info}/PKG-INFO +1 -1
- ygrader-2.0.0/ygrader/feedback.py +0 -218
- {ygrader-2.0.0 → ygrader-2.1.0}/LICENSE +0 -0
- {ygrader-2.0.0 → ygrader-2.1.0}/setup.cfg +0 -0
- {ygrader-2.0.0 → ygrader-2.1.0}/test/test_interactive.py +0 -0
- {ygrader-2.0.0 → ygrader-2.1.0}/test/test_unittest.py +0 -0
- {ygrader-2.0.0 → ygrader-2.1.0}/ygrader/deductions.py +0 -0
- {ygrader-2.0.0 → ygrader-2.1.0}/ygrader/grader.py +0 -0
- {ygrader-2.0.0 → ygrader-2.1.0}/ygrader/grades_csv.py +0 -0
- {ygrader-2.0.0 → ygrader-2.1.0}/ygrader/grading_item.py +0 -0
- {ygrader-2.0.0 → ygrader-2.1.0}/ygrader/score_input.py +0 -0
- {ygrader-2.0.0 → ygrader-2.1.0}/ygrader/send_ctrl_backtick.ahk +0 -0
- {ygrader-2.0.0 → ygrader-2.1.0}/ygrader/student_repos.py +0 -0
- {ygrader-2.0.0 → ygrader-2.1.0}/ygrader/upstream_merger.py +0 -0
- {ygrader-2.0.0 → ygrader-2.1.0}/ygrader/utils.py +0 -0
- {ygrader-2.0.0 → ygrader-2.1.0}/ygrader.egg-info/SOURCES.txt +0 -0
- {ygrader-2.0.0 → ygrader-2.1.0}/ygrader.egg-info/dependency_links.txt +0 -0
- {ygrader-2.0.0 → ygrader-2.1.0}/ygrader.egg-info/requires.txt +0 -0
- {ygrader-2.0.0 → 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="2.
|
|
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",
|
|
@@ -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 [""]
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|