ygrader 2.1.0__tar.gz → 2.3.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.1.0/ygrader.egg-info → ygrader-2.3.0}/PKG-INFO +1 -1
- {ygrader-2.1.0 → ygrader-2.3.0}/setup.py +1 -1
- {ygrader-2.1.0 → ygrader-2.3.0}/ygrader/deductions.py +45 -21
- {ygrader-2.1.0 → ygrader-2.3.0}/ygrader/feedback.py +130 -11
- {ygrader-2.1.0 → ygrader-2.3.0}/ygrader/grader.py +30 -50
- {ygrader-2.1.0 → ygrader-2.3.0}/ygrader/grading_item.py +61 -53
- ygrader-2.3.0/ygrader/score_input.py +324 -0
- {ygrader-2.1.0 → ygrader-2.3.0/ygrader.egg-info}/PKG-INFO +1 -1
- ygrader-2.1.0/ygrader/score_input.py +0 -231
- {ygrader-2.1.0 → ygrader-2.3.0}/LICENSE +0 -0
- {ygrader-2.1.0 → ygrader-2.3.0}/setup.cfg +0 -0
- {ygrader-2.1.0 → ygrader-2.3.0}/test/test_interactive.py +0 -0
- {ygrader-2.1.0 → ygrader-2.3.0}/test/test_unittest.py +0 -0
- {ygrader-2.1.0 → ygrader-2.3.0}/ygrader/__init__.py +0 -0
- {ygrader-2.1.0 → ygrader-2.3.0}/ygrader/grades_csv.py +0 -0
- {ygrader-2.1.0 → ygrader-2.3.0}/ygrader/grading_item_config.py +0 -0
- {ygrader-2.1.0 → ygrader-2.3.0}/ygrader/send_ctrl_backtick.ahk +0 -0
- {ygrader-2.1.0 → ygrader-2.3.0}/ygrader/student_repos.py +0 -0
- {ygrader-2.1.0 → ygrader-2.3.0}/ygrader/upstream_merger.py +0 -0
- {ygrader-2.1.0 → ygrader-2.3.0}/ygrader/utils.py +0 -0
- {ygrader-2.1.0 → ygrader-2.3.0}/ygrader.egg-info/SOURCES.txt +0 -0
- {ygrader-2.1.0 → ygrader-2.3.0}/ygrader.egg-info/dependency_links.txt +0 -0
- {ygrader-2.1.0 → ygrader-2.3.0}/ygrader.egg-info/requires.txt +0 -0
- {ygrader-2.1.0 → ygrader-2.3.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.3.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.
|
|
@@ -448,3 +448,27 @@ class StudentDeductions:
|
|
|
448
448
|
deduction_items = self.deductions_by_students.get(student_key, [])
|
|
449
449
|
|
|
450
450
|
return sum(item.points for item in deduction_items)
|
|
451
|
+
|
|
452
|
+
def get_graded_students(self) -> List[tuple]:
|
|
453
|
+
"""Get a list of all graded students.
|
|
454
|
+
|
|
455
|
+
Returns:
|
|
456
|
+
List of net_id tuples for all students who have been graded.
|
|
457
|
+
"""
|
|
458
|
+
return list(self.deductions_by_students.keys())
|
|
459
|
+
|
|
460
|
+
def find_student_by_netid(self, netid: str) -> Optional[tuple]:
|
|
461
|
+
"""Find a student's full net_ids tuple by a single net_id.
|
|
462
|
+
|
|
463
|
+
This is useful for finding group members when you only know one net_id.
|
|
464
|
+
|
|
465
|
+
Args:
|
|
466
|
+
netid: A single net_id to search for.
|
|
467
|
+
|
|
468
|
+
Returns:
|
|
469
|
+
The full net_ids tuple if found, None otherwise.
|
|
470
|
+
"""
|
|
471
|
+
for student_key in self.deductions_by_students:
|
|
472
|
+
if netid in student_key:
|
|
473
|
+
return student_key
|
|
474
|
+
return None
|
|
@@ -1,29 +1,65 @@
|
|
|
1
1
|
"""Module for generating student feedback files and grades CSV."""
|
|
2
2
|
|
|
3
|
+
import datetime
|
|
3
4
|
import pathlib
|
|
4
5
|
import zipfile
|
|
5
6
|
from typing import Callable, Dict, Optional, Tuple
|
|
6
7
|
|
|
8
|
+
import numpy as np
|
|
7
9
|
import pandas
|
|
8
10
|
|
|
9
11
|
from .deductions import StudentDeductions
|
|
10
12
|
from .grading_item_config import LearningSuiteColumn
|
|
11
|
-
from .utils import warning
|
|
13
|
+
from .utils import warning, print_color, TermColors
|
|
12
14
|
|
|
13
15
|
|
|
14
16
|
# Type alias for late penalty callback: (late_days, max_score, actual_score) -> new_score
|
|
15
17
|
LatePenaltyCallback = Callable[[int, float, float], float]
|
|
16
18
|
|
|
17
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
|
+
|
|
18
50
|
def _get_student_key_and_max_late_days(
|
|
19
51
|
net_id: str,
|
|
20
52
|
item_deductions: Dict[str, StudentDeductions],
|
|
53
|
+
due_date: Optional[datetime.datetime] = None,
|
|
54
|
+
due_date_exceptions: Optional[Dict[str, datetime.datetime]] = None,
|
|
21
55
|
) -> tuple:
|
|
22
56
|
"""Find the student key and maximum late days across all items.
|
|
23
57
|
|
|
24
58
|
Args:
|
|
25
59
|
net_id: The student's net ID.
|
|
26
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.
|
|
27
63
|
|
|
28
64
|
Returns:
|
|
29
65
|
Tuple of (student_key or None, max_late_days).
|
|
@@ -31,6 +67,9 @@ def _get_student_key_and_max_late_days(
|
|
|
31
67
|
max_late_days = 0
|
|
32
68
|
found_student_key = None
|
|
33
69
|
|
|
70
|
+
if due_date_exceptions is None:
|
|
71
|
+
due_date_exceptions = {}
|
|
72
|
+
|
|
34
73
|
for deductions_obj in item_deductions.values():
|
|
35
74
|
if not deductions_obj:
|
|
36
75
|
continue
|
|
@@ -39,12 +78,12 @@ def _get_student_key_and_max_late_days(
|
|
|
39
78
|
student_key = None
|
|
40
79
|
if (net_id,) in deductions_obj.deductions_by_students:
|
|
41
80
|
student_key = (net_id,)
|
|
42
|
-
elif (net_id,) in deductions_obj.
|
|
81
|
+
elif (net_id,) in deductions_obj.submit_time_by_students:
|
|
43
82
|
student_key = (net_id,)
|
|
44
83
|
else:
|
|
45
84
|
# Check for multi-student keys containing this net_id
|
|
46
85
|
for key in set(deductions_obj.deductions_by_students.keys()) | set(
|
|
47
|
-
deductions_obj.
|
|
86
|
+
deductions_obj.submit_time_by_students.keys()
|
|
48
87
|
):
|
|
49
88
|
if net_id in key:
|
|
50
89
|
student_key = key
|
|
@@ -52,8 +91,25 @@ def _get_student_key_and_max_late_days(
|
|
|
52
91
|
|
|
53
92
|
if student_key:
|
|
54
93
|
found_student_key = student_key
|
|
55
|
-
|
|
56
|
-
|
|
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)
|
|
57
113
|
|
|
58
114
|
return found_student_key, max_late_days
|
|
59
115
|
|
|
@@ -62,8 +118,11 @@ def _calculate_student_score(
|
|
|
62
118
|
net_id: str,
|
|
63
119
|
ls_column: LearningSuiteColumn,
|
|
64
120
|
item_deductions: Dict[str, StudentDeductions],
|
|
121
|
+
*,
|
|
65
122
|
late_penalty_callback: Optional[LatePenaltyCallback] = None,
|
|
66
123
|
warn_on_missing_callback: bool = True,
|
|
124
|
+
due_date: Optional[datetime.datetime] = None,
|
|
125
|
+
due_date_exceptions: Optional[Dict[str, datetime.datetime]] = None,
|
|
67
126
|
) -> Tuple[float, float, int]:
|
|
68
127
|
"""Calculate a student's final score.
|
|
69
128
|
|
|
@@ -73,6 +132,8 @@ def _calculate_student_score(
|
|
|
73
132
|
item_deductions: Mapping from item name to StudentDeductions.
|
|
74
133
|
late_penalty_callback: Optional callback for late penalty.
|
|
75
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.
|
|
76
137
|
|
|
77
138
|
Returns:
|
|
78
139
|
Tuple of (final_score, total_possible, max_late_days).
|
|
@@ -102,7 +163,9 @@ def _calculate_student_score(
|
|
|
102
163
|
score = max(0, total_possible - total_deductions)
|
|
103
164
|
|
|
104
165
|
# Get max late days
|
|
105
|
-
_, max_late_days = _get_student_key_and_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
|
+
)
|
|
106
169
|
|
|
107
170
|
# Apply late penalty if applicable
|
|
108
171
|
if max_late_days > 0:
|
|
@@ -124,6 +187,8 @@ def assemble_grades(
|
|
|
124
187
|
output_zip_path: Optional[pathlib.Path] = None,
|
|
125
188
|
output_csv_path: Optional[pathlib.Path] = None,
|
|
126
189
|
late_penalty_callback: Optional[LatePenaltyCallback] = None,
|
|
190
|
+
due_date: Optional[datetime.datetime] = None,
|
|
191
|
+
due_date_exceptions: Optional[Dict[str, datetime.datetime]] = None,
|
|
127
192
|
) -> Tuple[Optional[pathlib.Path], Optional[pathlib.Path]]:
|
|
128
193
|
"""Generate feedback zip and/or grades CSV from deductions.
|
|
129
194
|
|
|
@@ -135,6 +200,8 @@ def assemble_grades(
|
|
|
135
200
|
output_csv_path: Path for the output CSV file. If None, no CSV is generated.
|
|
136
201
|
late_penalty_callback: Optional callback function that takes
|
|
137
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.
|
|
138
205
|
|
|
139
206
|
Returns:
|
|
140
207
|
Tuple of (feedback_zip_path or None, grades_csv_path or None).
|
|
@@ -170,15 +237,58 @@ def assemble_grades(
|
|
|
170
237
|
last_name = str(student_row["Last Name"]).strip()
|
|
171
238
|
net_id = str(student_row["Net ID"]).strip()
|
|
172
239
|
|
|
173
|
-
#
|
|
174
|
-
|
|
240
|
+
# Check for partial grades (graded for some items but not others)
|
|
241
|
+
items_graded = []
|
|
242
|
+
items_not_graded = []
|
|
243
|
+
for item in ls_column.items:
|
|
244
|
+
deductions_obj = subitem_deductions.get(item.name)
|
|
245
|
+
if deductions_obj and deductions_obj.is_student_graded((net_id,)):
|
|
246
|
+
items_graded.append(item.name)
|
|
247
|
+
else:
|
|
248
|
+
# Also check for multi-student keys containing this net_id
|
|
249
|
+
found = False
|
|
250
|
+
if deductions_obj:
|
|
251
|
+
for key in deductions_obj.deductions_by_students.keys():
|
|
252
|
+
if net_id in key:
|
|
253
|
+
items_graded.append(item.name)
|
|
254
|
+
found = True
|
|
255
|
+
break
|
|
256
|
+
if not found:
|
|
257
|
+
items_not_graded.append(item.name)
|
|
258
|
+
|
|
259
|
+
if items_graded and items_not_graded:
|
|
260
|
+
print_color(
|
|
261
|
+
TermColors.YELLOW,
|
|
262
|
+
f"Partial grade: {net_id} graded for [{', '.join(items_graded)}] "
|
|
263
|
+
f"but NOT [{', '.join(items_not_graded)}]",
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
# Calculate score before late penalty
|
|
267
|
+
score_before_late, total_possible, max_late_days = _calculate_student_score(
|
|
175
268
|
net_id=net_id,
|
|
176
269
|
ls_column=ls_column,
|
|
177
270
|
item_deductions=subitem_deductions,
|
|
178
|
-
late_penalty_callback=
|
|
179
|
-
warn_on_missing_callback=
|
|
271
|
+
late_penalty_callback=None, # Don't apply late penalty yet
|
|
272
|
+
warn_on_missing_callback=False,
|
|
273
|
+
due_date=due_date,
|
|
274
|
+
due_date_exceptions=due_date_exceptions,
|
|
180
275
|
)
|
|
181
276
|
|
|
277
|
+
# Apply late penalty if applicable
|
|
278
|
+
final_score = score_before_late
|
|
279
|
+
if max_late_days > 0 and late_penalty_callback:
|
|
280
|
+
final_score = max(
|
|
281
|
+
0,
|
|
282
|
+
late_penalty_callback(
|
|
283
|
+
max_late_days, total_possible, score_before_late
|
|
284
|
+
),
|
|
285
|
+
)
|
|
286
|
+
print_color(
|
|
287
|
+
TermColors.YELLOW,
|
|
288
|
+
f"Late: {net_id} ({max_late_days} day{'s' if max_late_days != 1 else ''}): "
|
|
289
|
+
f"{score_before_late:.1f} -> {final_score:.1f}",
|
|
290
|
+
)
|
|
291
|
+
|
|
182
292
|
# Add to grades data
|
|
183
293
|
if output_csv_path:
|
|
184
294
|
grades_data.append(
|
|
@@ -192,6 +302,8 @@ def assemble_grades(
|
|
|
192
302
|
ls_column=ls_column,
|
|
193
303
|
subitem_deductions=subitem_deductions,
|
|
194
304
|
late_penalty_callback=late_penalty_callback,
|
|
305
|
+
due_date=due_date,
|
|
306
|
+
due_date_exceptions=due_date_exceptions,
|
|
195
307
|
)
|
|
196
308
|
|
|
197
309
|
filename = (
|
|
@@ -226,7 +338,10 @@ def _generate_student_feedback(
|
|
|
226
338
|
student_row: pandas.Series,
|
|
227
339
|
ls_column: LearningSuiteColumn,
|
|
228
340
|
subitem_deductions: Dict[str, StudentDeductions],
|
|
341
|
+
*,
|
|
229
342
|
late_penalty_callback: Optional[LatePenaltyCallback] = None,
|
|
343
|
+
due_date: Optional[datetime.datetime] = None,
|
|
344
|
+
due_date_exceptions: Optional[Dict[str, datetime.datetime]] = None,
|
|
230
345
|
) -> str:
|
|
231
346
|
"""Generate the feedback text content for a single student.
|
|
232
347
|
|
|
@@ -235,6 +350,8 @@ def _generate_student_feedback(
|
|
|
235
350
|
ls_column: The LearningSuiteColumn object.
|
|
236
351
|
subitem_deductions: Mapping from subitem name to StudentDeductions.
|
|
237
352
|
late_penalty_callback: Optional callback for calculating late penalty.
|
|
353
|
+
due_date: The default due date for the assignment.
|
|
354
|
+
due_date_exceptions: Mapping from net_id to exception due date.
|
|
238
355
|
|
|
239
356
|
Returns:
|
|
240
357
|
The formatted feedback text.
|
|
@@ -317,7 +434,9 @@ def _generate_student_feedback(
|
|
|
317
434
|
score_before_late = max(0, total_points_possible - total_points_deducted)
|
|
318
435
|
|
|
319
436
|
# Get max late days for this student
|
|
320
|
-
_, max_late_days = _get_student_key_and_max_late_days(
|
|
437
|
+
_, max_late_days = _get_student_key_and_max_late_days(
|
|
438
|
+
net_id, subitem_deductions, due_date, due_date_exceptions
|
|
439
|
+
)
|
|
321
440
|
|
|
322
441
|
# Late penalty section
|
|
323
442
|
lines.append("")
|
|
@@ -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)
|
|
@@ -507,8 +459,20 @@ class Grader:
|
|
|
507
459
|
self._run_grading(student_grades_df, grouped_df)
|
|
508
460
|
|
|
509
461
|
def _run_grading(self, student_grades_df, grouped_df):
|
|
462
|
+
# Sort by last name for consistent grading order
|
|
463
|
+
# After groupby().agg(list), "Last Name" is a list, so we sort by the first element
|
|
464
|
+
sorted_df = grouped_df.sort_values(
|
|
465
|
+
by="Last Name", key=lambda x: x.apply(lambda names: names[0].lower())
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
# Convert to list for index-based iteration (needed for going back)
|
|
469
|
+
rows_list = list(sorted_df.iterrows())
|
|
470
|
+
idx = 0
|
|
471
|
+
prev_idx = None # Track previous student index for undo
|
|
472
|
+
|
|
510
473
|
# Loop through all of the students/groups and perform grading
|
|
511
|
-
|
|
474
|
+
while idx < len(rows_list):
|
|
475
|
+
_, row = rows_list[idx]
|
|
512
476
|
first_names = grades_csv.get_first_names(row)
|
|
513
477
|
last_names = grades_csv.get_last_names(row)
|
|
514
478
|
net_ids = grades_csv.get_net_ids(row)
|
|
@@ -521,6 +485,7 @@ class Grader:
|
|
|
521
485
|
|
|
522
486
|
if sum(num_group_members_need_grade_per_item) == 0:
|
|
523
487
|
# This student/group is already fully graded
|
|
488
|
+
idx += 1
|
|
524
489
|
continue
|
|
525
490
|
|
|
526
491
|
# Print name(s) of who we are grading
|
|
@@ -539,6 +504,7 @@ class Grader:
|
|
|
539
504
|
# Code from zip will return modified time (epoch, float). Code from github will return True.
|
|
540
505
|
success = self._get_student_code(row, student_work_path)
|
|
541
506
|
if not success:
|
|
507
|
+
idx += 1
|
|
542
508
|
continue
|
|
543
509
|
|
|
544
510
|
# Format student code
|
|
@@ -566,13 +532,23 @@ class Grader:
|
|
|
566
532
|
)
|
|
567
533
|
except CallbackFailed as e:
|
|
568
534
|
print_color(TermColors.RED, repr(e))
|
|
535
|
+
idx += 1
|
|
569
536
|
continue
|
|
570
537
|
except KeyboardInterrupt:
|
|
571
538
|
pass
|
|
572
539
|
|
|
573
540
|
# Loop through all items that are to be graded
|
|
541
|
+
go_back = False
|
|
574
542
|
for item in self.items:
|
|
575
|
-
item.run_grading(student_grades_df, row, callback_args)
|
|
543
|
+
go_back = item.run_grading(student_grades_df, row, callback_args)
|
|
544
|
+
if go_back:
|
|
545
|
+
break # Stop grading items for this student if going back
|
|
546
|
+
|
|
547
|
+
if go_back and prev_idx is not None:
|
|
548
|
+
# Go back to previous student
|
|
549
|
+
idx = prev_idx
|
|
550
|
+
prev_idx = None # Clear so we can't go back twice in a row
|
|
551
|
+
continue
|
|
576
552
|
|
|
577
553
|
if self.dry_run_first:
|
|
578
554
|
print_color(
|
|
@@ -581,6 +557,10 @@ class Grader:
|
|
|
581
557
|
)
|
|
582
558
|
break
|
|
583
559
|
|
|
560
|
+
# Move to next student and remember this one for potential undo
|
|
561
|
+
prev_idx = idx
|
|
562
|
+
idx += 1
|
|
563
|
+
|
|
584
564
|
def _unzip_submissions(self):
|
|
585
565
|
with zipfile.ZipFile(self.learning_suite_submissions_zip_path, "r") as f:
|
|
586
566
|
for zip_info in f.infolist():
|