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.
Files changed (24) hide show
  1. {ygrader-2.1.0/ygrader.egg-info → ygrader-2.3.0}/PKG-INFO +1 -1
  2. {ygrader-2.1.0 → ygrader-2.3.0}/setup.py +1 -1
  3. {ygrader-2.1.0 → ygrader-2.3.0}/ygrader/deductions.py +45 -21
  4. {ygrader-2.1.0 → ygrader-2.3.0}/ygrader/feedback.py +130 -11
  5. {ygrader-2.1.0 → ygrader-2.3.0}/ygrader/grader.py +30 -50
  6. {ygrader-2.1.0 → ygrader-2.3.0}/ygrader/grading_item.py +61 -53
  7. ygrader-2.3.0/ygrader/score_input.py +324 -0
  8. {ygrader-2.1.0 → ygrader-2.3.0/ygrader.egg-info}/PKG-INFO +1 -1
  9. ygrader-2.1.0/ygrader/score_input.py +0 -231
  10. {ygrader-2.1.0 → ygrader-2.3.0}/LICENSE +0 -0
  11. {ygrader-2.1.0 → ygrader-2.3.0}/setup.cfg +0 -0
  12. {ygrader-2.1.0 → ygrader-2.3.0}/test/test_interactive.py +0 -0
  13. {ygrader-2.1.0 → ygrader-2.3.0}/test/test_unittest.py +0 -0
  14. {ygrader-2.1.0 → ygrader-2.3.0}/ygrader/__init__.py +0 -0
  15. {ygrader-2.1.0 → ygrader-2.3.0}/ygrader/grades_csv.py +0 -0
  16. {ygrader-2.1.0 → ygrader-2.3.0}/ygrader/grading_item_config.py +0 -0
  17. {ygrader-2.1.0 → ygrader-2.3.0}/ygrader/send_ctrl_backtick.ahk +0 -0
  18. {ygrader-2.1.0 → ygrader-2.3.0}/ygrader/student_repos.py +0 -0
  19. {ygrader-2.1.0 → ygrader-2.3.0}/ygrader/upstream_merger.py +0 -0
  20. {ygrader-2.1.0 → ygrader-2.3.0}/ygrader/utils.py +0 -0
  21. {ygrader-2.1.0 → ygrader-2.3.0}/ygrader.egg-info/SOURCES.txt +0 -0
  22. {ygrader-2.1.0 → ygrader-2.3.0}/ygrader.egg-info/dependency_links.txt +0 -0
  23. {ygrader-2.1.0 → ygrader-2.3.0}/ygrader.egg-info/requires.txt +0 -0
  24. {ygrader-2.1.0 → ygrader-2.3.0}/ygrader.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ygrader
3
- Version: 2.1.0
3
+ Version: 2.3.0
4
4
  Summary: Grading scripts used in BYU's Electrical and Computer Engineering Department
5
5
  Home-page: https://github.com/byu-cpe/ygrader
6
6
  Author: Jeff Goeders
@@ -4,7 +4,7 @@ setup(
4
4
  name="ygrader",
5
5
  packages=["ygrader"],
6
6
  package_data={"ygrader": ["*.ahk"]},
7
- version="2.1.0",
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.days_late_by_students = {}
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 days_late if present
106
- if "days_late" in entry:
107
- self.days_late_by_students[student_key] = entry["days_late"]
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 days_late)
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.days_late_by_students.keys()
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
- {"days_late": self.days_late_by_students[student_key]}
162
- if student_key in self.days_late_by_students
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.days_late_by_students:
379
- del self.days_late_by_students[student_key]
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 set_days_late(self, net_ids: tuple, days_late: int):
409
- """Set the number of days late for a student.
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
- days_late: Number of business days late (0 or None to remove).
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 days_late and days_late > 0:
417
- self.days_late_by_students[student_key] = days_late
418
- elif student_key in self.days_late_by_students:
419
- del self.days_late_by_students[student_key]
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 get_days_late(self, net_ids: tuple) -> Optional[int]:
423
- """Get the number of days late for a student.
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
- Number of business days late, or None if not set/on time.
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.days_late_by_students.get(student_key)
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.days_late_by_students:
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.days_late_by_students.keys()
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
- days_late = deductions_obj.days_late_by_students.get(student_key, 0)
56
- max_late_days = max(max_late_days, days_late)
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(net_id, item_deductions)
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
- # Calculate final score (only warn once, when generating CSV)
174
- final_score, _, _ = _calculate_student_score(
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=late_penalty_callback,
179
- warn_on_missing_callback=(output_csv_path is not None),
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(net_id, subitem_deductions)
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
- for _, row in grouped_df.iterrows():
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():