ygrader 2.6.5__tar.gz → 2.6.7__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 (25) hide show
  1. {ygrader-2.6.5/ygrader.egg-info → ygrader-2.6.7}/PKG-INFO +1 -1
  2. {ygrader-2.6.5 → ygrader-2.6.7}/setup.py +1 -1
  3. {ygrader-2.6.5 → ygrader-2.6.7}/ygrader/feedback.py +18 -1
  4. {ygrader-2.6.5 → ygrader-2.6.7}/ygrader/grader.py +32 -1
  5. {ygrader-2.6.5 → ygrader-2.6.7}/ygrader/grading_item.py +79 -58
  6. ygrader-2.6.7/ygrader/score_input.py +517 -0
  7. {ygrader-2.6.5 → ygrader-2.6.7/ygrader.egg-info}/PKG-INFO +1 -1
  8. ygrader-2.6.5/ygrader/score_input.py +0 -343
  9. {ygrader-2.6.5 → ygrader-2.6.7}/LICENSE +0 -0
  10. {ygrader-2.6.5 → ygrader-2.6.7}/setup.cfg +0 -0
  11. {ygrader-2.6.5 → ygrader-2.6.7}/test/test_interactive.py +0 -0
  12. {ygrader-2.6.5 → ygrader-2.6.7}/test/test_unittest.py +0 -0
  13. {ygrader-2.6.5 → ygrader-2.6.7}/ygrader/__init__.py +0 -0
  14. {ygrader-2.6.5 → ygrader-2.6.7}/ygrader/deductions.py +0 -0
  15. {ygrader-2.6.5 → ygrader-2.6.7}/ygrader/grades_csv.py +0 -0
  16. {ygrader-2.6.5 → ygrader-2.6.7}/ygrader/grading_item_config.py +0 -0
  17. {ygrader-2.6.5 → ygrader-2.6.7}/ygrader/remote.py +0 -0
  18. {ygrader-2.6.5 → ygrader-2.6.7}/ygrader/send_ctrl_backtick.ahk +0 -0
  19. {ygrader-2.6.5 → ygrader-2.6.7}/ygrader/student_repos.py +0 -0
  20. {ygrader-2.6.5 → ygrader-2.6.7}/ygrader/upstream_merger.py +0 -0
  21. {ygrader-2.6.5 → ygrader-2.6.7}/ygrader/utils.py +0 -0
  22. {ygrader-2.6.5 → ygrader-2.6.7}/ygrader.egg-info/SOURCES.txt +0 -0
  23. {ygrader-2.6.5 → ygrader-2.6.7}/ygrader.egg-info/dependency_links.txt +0 -0
  24. {ygrader-2.6.5 → ygrader-2.6.7}/ygrader.egg-info/requires.txt +0 -0
  25. {ygrader-2.6.5 → ygrader-2.6.7}/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.6.5
3
+ Version: 2.6.7
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.6.5",
7
+ version="2.6.7",
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",
@@ -293,11 +293,19 @@ def assemble_grades(
293
293
 
294
294
  if items_graded and items_not_graded:
295
295
  print_color(
296
- TermColors.YELLOW,
296
+ TermColors.RED,
297
297
  f"Partial grade: {net_id} graded for [{', '.join(items_graded)}] "
298
298
  f"but NOT [{', '.join(items_not_graded)}]",
299
299
  )
300
300
 
301
+ # Check if student has no grades at all
302
+ student_graded = len(items_graded) > 0
303
+ if not student_graded:
304
+ print_color(
305
+ TermColors.YELLOW,
306
+ f"No grades: {net_id} has no grades, receiving 0",
307
+ )
308
+
301
309
  # Get submit info for this student
302
310
  _, effective_due_date, submitted_datetime = (
303
311
  _get_student_key_and_submit_info(
@@ -353,6 +361,7 @@ def assemble_grades(
353
361
  late_penalty_callback=late_penalty_callback,
354
362
  due_date=due_date,
355
363
  due_date_exceptions=due_date_exceptions,
364
+ student_graded=student_graded,
356
365
  )
357
366
 
358
367
  filename = (
@@ -391,6 +400,7 @@ def _generate_student_feedback(
391
400
  late_penalty_callback: Optional[LatePenaltyCallback] = None,
392
401
  due_date: Optional[datetime.datetime] = None,
393
402
  due_date_exceptions: Optional[Dict[str, datetime.datetime]] = None,
403
+ student_graded: bool = True,
394
404
  ) -> str:
395
405
  """Generate the feedback text content for a single student.
396
406
 
@@ -401,6 +411,7 @@ def _generate_student_feedback(
401
411
  late_penalty_callback: Optional callback for calculating late penalty.
402
412
  due_date: The default due date for the assignment.
403
413
  due_date_exceptions: Mapping from net_id to exception due date.
414
+ student_graded: Whether the student was graded at all (False if no submission).
404
415
 
405
416
  Returns:
406
417
  The formatted feedback text.
@@ -517,6 +528,12 @@ def _generate_student_feedback(
517
528
  lines.append(
518
529
  f"{late_label:<{item_col_width}} {-late_penalty_points:>{score_col_width}.1f}"
519
530
  )
531
+ elif not student_graded:
532
+ # Student was never graded (no submission)
533
+ final_score = score_before_late
534
+ lines.append(
535
+ f"{'Late Penalty:':<{item_col_width}} {'No Submission':>{score_col_width}}"
536
+ )
520
537
  else:
521
538
  final_score = score_before_late
522
539
  lines.append(
@@ -21,6 +21,7 @@ import pandas
21
21
 
22
22
  from . import grades_csv, student_repos, utils
23
23
  from .grading_item import GradeItem
24
+ from .score_input import show_completion_menu
24
25
  from .utils import (
25
26
  CallbackFailed,
26
27
  TermColors,
@@ -98,6 +99,8 @@ class Grader:
98
99
  self.items = []
99
100
  self.code_source = None
100
101
  self.prep_fcn = None
102
+ self.last_graded_net_ids = None # Track last fully graded student for undo
103
+ self.interactive_grading_occurred = False # Track if any user interaction happened
101
104
  self.learning_suite_submissions_zip_path = None
102
105
  self.github_csv_path = None
103
106
  self.github_csv_col_name = None
@@ -505,6 +508,7 @@ class Grader:
505
508
  rows_list = list(sorted_df.iterrows())
506
509
  idx = 0
507
510
  prev_idx = None # Track previous student index for undo
511
+ any_student_needed_grading = False # Track if any student needed grading
508
512
 
509
513
  # Loop through all of the students/groups and perform grading
510
514
  while idx < len(rows_list):
@@ -524,6 +528,8 @@ class Grader:
524
528
  idx += 1
525
529
  continue
526
530
 
531
+ any_student_needed_grading = True
532
+
527
533
  # Print name(s) of who we are grading
528
534
  student_work_path = self.work_path / utils.names_to_dir(
529
535
  first_names, last_names, net_ids
@@ -588,8 +594,18 @@ class Grader:
588
594
  # Go back to previous student
589
595
  idx = prev_idx
590
596
  prev_idx = None # Clear so we can't go back twice in a row
597
+ self.last_graded_net_ids = None # Clear since we're undoing
591
598
  continue
592
599
 
600
+ # Track this student as last graded (only if all items completed normally)
601
+ if (
602
+ not go_back
603
+ and not self.build_only
604
+ and not self.dry_run_first
605
+ and not self.dry_run_all
606
+ ):
607
+ self.last_graded_net_ids = tuple(net_ids)
608
+
593
609
  if self.dry_run_first:
594
610
  print_color(
595
611
  TermColors.YELLOW,
@@ -601,6 +617,18 @@ class Grader:
601
617
  prev_idx = idx
602
618
  idx += 1
603
619
 
620
+ # Show completion menu when all students are done (unless in special modes)
621
+ # Show if interactive grading occurred OR if no students needed grading (all already graded)
622
+ if (
623
+ not self.build_only
624
+ and not self.dry_run_first
625
+ and not self.dry_run_all
626
+ and (self.interactive_grading_occurred or not any_student_needed_grading)
627
+ ):
628
+ # Get names_by_netid from first item for display purposes
629
+ names_by_netid = self.items[0].names_by_netid if self.items else None
630
+ show_completion_menu(self.items, names_by_netid)
631
+
604
632
  def _process_single_student_build(self, row):
605
633
  """Process a single student for parallel build mode. Returns (net_ids, success, message, log_path)."""
606
634
  first_names = grades_csv.get_first_names(row)
@@ -896,7 +924,10 @@ class Grader:
896
924
  student_work_path.mkdir(parents=True, exist_ok=True)
897
925
 
898
926
  # Clone student repo
899
- print("Student repo url: " + row["github_url"], file=output)
927
+ https_url = student_repos.convert_github_url_format(
928
+ row["github_url"], to_https=True
929
+ )
930
+ print("Student repo url: " + https_url, file=output)
900
931
  if not student_repos.clone_repo(
901
932
  row["github_url"], self.github_tag, student_work_path, output=output
902
933
  ):
@@ -12,7 +12,7 @@ from .utils import (
12
12
  )
13
13
  from . import grades_csv
14
14
  from .deductions import StudentDeductions
15
- from .score_input import get_score, ScoreResult
15
+ from .score_input import get_score, MenuCommand
16
16
 
17
17
 
18
18
  class GradeItem:
@@ -36,7 +36,6 @@ class GradeItem:
36
36
  self.max_points = max_points
37
37
  self.fcn_args_dict = fcn_args_dict if fcn_args_dict is not None else {}
38
38
  self.student_deductions = StudentDeductions(deductions_yaml_path)
39
- self.last_graded_net_ids = None # Track last graded student for undo
40
39
  self.names_by_netid = (
41
40
  self._build_names_lookup()
42
41
  ) # net_id -> (first_name, last_name)
@@ -144,43 +143,18 @@ class GradeItem:
144
143
  )
145
144
 
146
145
  # Verify workflow hash if configured
147
- if self.grader.workflow_hash is not None:
148
- student_code_path = callback_args.get("student_code_path")
149
- if student_code_path:
150
- workflow_file_path = (
151
- student_code_path / ".github" / "workflows" / "submission.yml"
152
- )
153
- try:
154
- verify_workflow_hash(
155
- workflow_file_path, self.grader.workflow_hash
156
- )
157
- except WorkflowHashError as e:
158
- print("")
159
- print_color(TermColors.RED, "=" * 70)
160
- print_color(
161
- TermColors.RED,
162
- "WARNING: WORKFLOW FILE VERIFICATION FAILED!",
163
- )
164
- print_color(TermColors.RED, "=" * 70)
165
- print_color(TermColors.RED, str(e))
166
- print_color(TermColors.RED, "")
167
- print_color(
168
- TermColors.RED,
169
- "This student may have modified the GitHub workflow system.",
170
- )
171
- print_color(
172
- TermColors.RED, "The submission date CANNOT be guaranteed."
173
- )
174
- print_color(TermColors.RED, "")
175
- print_color(
176
- TermColors.RED,
177
- "Please contact the instructor before grading this student.",
178
- )
179
- print_color(TermColors.RED, "=" * 70)
180
- print("")
146
+ workflow_errors = []
147
+ student_code_path = callback_args.get("student_code_path")
148
+ if self.grader.workflow_hash is not None and student_code_path:
149
+ workflow_file_path = (
150
+ student_code_path / ".github" / "workflows" / "submission.yml"
151
+ )
152
+ try:
153
+ verify_workflow_hash(workflow_file_path, self.grader.workflow_hash)
154
+ except WorkflowHashError as e:
155
+ workflow_errors.append(f"Workflow hash mismatch: {e}")
181
156
 
182
157
  # Display submission date if available and store for later late calculation
183
- student_code_path = callback_args.get("student_code_path")
184
158
  # Store submission time but don't save yet - will be saved only after successful grading
185
159
  pending_submit_time = None
186
160
  if student_code_path:
@@ -188,10 +162,12 @@ class GradeItem:
188
162
  if submission_date_path.is_file():
189
163
  try:
190
164
  with open(submission_date_path, encoding="utf-8") as f:
191
- submission_time = datetime.datetime.strptime(
192
- f.read().strip(),
193
- "%a %b %d %H:%M:%S %Z %Y",
194
- )
165
+ date_str = f.read().strip()
166
+ # Expected format from workflow: "Sun Jan 12 21:51:14 MST 2026"
167
+ submission_time = datetime.datetime.strptime(
168
+ date_str,
169
+ "%a %b %d %H:%M:%S %Z %Y",
170
+ )
195
171
  print_color(
196
172
  TermColors.BLUE,
197
173
  f"Submitted: {submission_time.strftime('%Y-%m-%d %H:%M:%S')}",
@@ -199,10 +175,46 @@ class GradeItem:
199
175
  # Store as ISO format for later late calculation
200
176
  pending_submit_time = submission_time.isoformat()
201
177
  except (ValueError, IOError) as e:
202
- print_color(
203
- TermColors.YELLOW,
204
- f"Could not parse submission date: {e}",
205
- )
178
+ workflow_errors.append(f"Submission date parsing failed: {e}")
179
+
180
+ # Print unified warning if any workflow-related errors occurred
181
+ if workflow_errors:
182
+ print("")
183
+ print_color(TermColors.RED, "=" * 70)
184
+ print_color(
185
+ TermColors.RED,
186
+ "WARNING: WORKFLOW VERIFICATION FAILED!",
187
+ )
188
+ print_color(TermColors.RED, "=" * 70)
189
+ for err in workflow_errors:
190
+ print_color(TermColors.RED, f" - {err}")
191
+ print_color(TermColors.RED, "")
192
+ print_color(
193
+ TermColors.RED,
194
+ "This student may have modified the GitHub workflow system.",
195
+ )
196
+ print_color(TermColors.RED, "The submission date CANNOT be guaranteed.")
197
+ print_color(TermColors.RED, "")
198
+ print_color(
199
+ TermColors.RED,
200
+ "Please contact the instructor before grading this student.",
201
+ )
202
+ print_color(TermColors.RED, "=" * 70)
203
+ print("")
204
+
205
+ # Ask for confirmation before grading
206
+ while True:
207
+ response = (
208
+ input("Do you want to grade this student anyway? [y/n]: ")
209
+ .strip()
210
+ .lower()
211
+ )
212
+ if response in ("y", "yes"):
213
+ break
214
+ if response in ("n", "no"):
215
+ print_color(TermColors.BLUE, "Skipping student")
216
+ return False
217
+ print("Please enter 'y' or 'n'")
206
218
 
207
219
  # Process callback result:
208
220
  # - None: interactive mode (prompt for deductions)
@@ -248,6 +260,7 @@ class GradeItem:
248
260
 
249
261
  # callback_result is None - interactive mode
250
262
  # Prompt with deductions mode - handles everything internally
263
+ self.grader.interactive_grading_occurred = True
251
264
  try:
252
265
  score = get_score(
253
266
  concated_names,
@@ -256,35 +269,45 @@ class GradeItem:
256
269
  allow_rerun=self.grader.allow_rerun,
257
270
  student_deductions=self.student_deductions,
258
271
  net_ids=tuple(net_ids),
259
- last_graded_net_ids=self.last_graded_net_ids,
272
+ last_graded_net_ids=self.grader.last_graded_net_ids,
260
273
  names_by_netid=self.names_by_netid,
274
+ all_items=self.grader.items,
261
275
  )
262
276
  except KeyboardInterrupt:
263
277
  print_color(TermColors.RED, "\nExiting")
264
278
  sys.exit(0)
265
279
 
266
- if score == ScoreResult.SKIP:
280
+ if score == MenuCommand.SKIP:
267
281
  return False
268
- if score == ScoreResult.REBUILD:
282
+ if score == MenuCommand.BUILD:
269
283
  continue
270
- if score == ScoreResult.RERUN:
284
+ if score == MenuCommand.RERUN:
271
285
  # run again, but don't build
272
286
  build = False
273
287
  continue
274
- if score == ScoreResult.EXIT:
288
+ if score == MenuCommand.EXIT:
275
289
  print_color(TermColors.BLUE, "Exiting grader")
276
290
  sys.exit(0)
277
- if score == ScoreResult.UNDO_LAST:
291
+ if score == MenuCommand.UNDO:
278
292
  # Undo the last graded student and signal to go back
279
- if self.last_graded_net_ids is not None:
280
- self.student_deductions.clear_student_deductions(
281
- self.last_graded_net_ids
293
+ if self.grader.last_graded_net_ids is not None:
294
+ # Clear deductions for ALL items for the last graded student
295
+ for item in self.grader.items:
296
+ item.student_deductions.clear_student_deductions(
297
+ self.grader.last_graded_net_ids
298
+ )
299
+ # Also clear any partial grades for the CURRENT student
300
+ # so they start fresh when we come back to them
301
+ for item in self.grader.items:
302
+ item.student_deductions.clear_student_deductions(tuple(net_ids))
303
+ print_color(
304
+ TermColors.GREEN,
305
+ f"Undid grade for {', '.join(self.grader.last_graded_net_ids)} - going back to regrade",
282
306
  )
283
307
  print_color(
284
308
  TermColors.GREEN,
285
- f"Undid grade for {', '.join(self.last_graded_net_ids)} - going back to regrade",
309
+ f"Also cleared partial grades for {', '.join(net_ids)}",
286
310
  )
287
- self.last_graded_net_ids = None
288
311
  return True # Signal to go back to previous student
289
312
  continue
290
313
 
@@ -295,8 +318,6 @@ class GradeItem:
295
318
  tuple(net_ids), pending_submit_time
296
319
  )
297
320
  self.student_deductions.ensure_student_in_file(tuple(net_ids))
298
- # Track this student as last graded for undo functionality
299
- self.last_graded_net_ids = tuple(net_ids)
300
321
  return False # Normal completion
301
322
 
302
323
  # If we got here via break (CallbackFailed, build_only, dry_run, etc.)
@@ -0,0 +1,517 @@
1
+ """Module for prompting users for scores during grading."""
2
+
3
+ import sys
4
+ from enum import Enum
5
+
6
+ from .utils import TermColors, print_color
7
+
8
+
9
+ class MenuCommand(Enum):
10
+ """Enum representing menu commands, with character as value.
11
+
12
+ Used both for defining menu keys and as return values from get_score().
13
+ """
14
+
15
+ SKIP = "s"
16
+ BUILD = "b"
17
+ RERUN = "r"
18
+ NEW_DEDUCTION = "n"
19
+ DELETE_DEDUCTION = "d"
20
+ CLEAR_DEDUCTIONS = "0"
21
+ CHANGE_VALUE = "v"
22
+ MANAGE_GRADES = "g"
23
+ UNDO = "u"
24
+ EXIT = "e"
25
+
26
+ @property
27
+ def key_display(self):
28
+ """Return the key display string like '[s]'."""
29
+ return f"[{self.value}]"
30
+
31
+
32
+ def _print_menu_item(cmd, description, indent=" ", pad=10):
33
+ """Print a single menu item with consistent formatting."""
34
+ print(
35
+ indent
36
+ + TermColors.BLUE
37
+ + cmd.key_display.ljust(pad)
38
+ + TermColors.END
39
+ + description
40
+ )
41
+
42
+
43
+ def show_completion_menu(items, names_by_netid=None):
44
+ """Show a menu when all students have been graded.
45
+
46
+ Allows the user to manage grades, edit deduction types, etc.
47
+
48
+ Args:
49
+ items: List of GradeItem objects (to access their student_deductions)
50
+ names_by_netid: Dict mapping net_id -> (first_name, last_name) for search
51
+
52
+ Returns:
53
+ True if user wants to exit, False to continue showing the menu
54
+ """
55
+ if not items:
56
+ print_color(TermColors.YELLOW, "No grade items configured.")
57
+ return True
58
+
59
+ fpad2 = " " * 4
60
+
61
+ while True:
62
+ # Count total graded students across all items
63
+ all_graded_students = set()
64
+ for item in items:
65
+ for student_key in item.student_deductions.get_graded_students():
66
+ all_graded_students.add(student_key)
67
+
68
+ print("")
69
+ print("=" * 60)
70
+ print_color(TermColors.GREEN, "All students have been graded!")
71
+ print("=" * 60)
72
+ print("")
73
+
74
+ # Show summary
75
+ print(fpad2 + f"Total students graded: {len(all_graded_students)}")
76
+ print("")
77
+
78
+ # Show menu
79
+ menu_items = [
80
+ (MenuCommand.MANAGE_GRADES, "Manage grades (view/delete student grades)"),
81
+ (MenuCommand.DELETE_DEDUCTION, "Delete deduction type"),
82
+ (MenuCommand.CHANGE_VALUE, "Change deduction value"),
83
+ (MenuCommand.EXIT, "Exit grader"),
84
+ ]
85
+ print(fpad2 + "Options:")
86
+ for cmd, desc in menu_items:
87
+ _print_menu_item(cmd, desc, fpad2)
88
+ print("")
89
+
90
+ txt = input(TermColors.BLUE + ">>> " + TermColors.END).strip().lower()
91
+
92
+ if txt == MenuCommand.MANAGE_GRADES.value:
93
+ _manage_grades_interactive(items, names_by_netid)
94
+ elif txt == MenuCommand.DELETE_DEDUCTION.value:
95
+ selected_item = _select_item_interactive(
96
+ items, "delete deduction type from"
97
+ )
98
+ if selected_item:
99
+ selected_item.student_deductions.delete_deduction_type_interactive()
100
+ elif txt == MenuCommand.CHANGE_VALUE.value:
101
+ selected_item = _select_item_interactive(
102
+ items, "change deduction value for"
103
+ )
104
+ if selected_item:
105
+ selected_item.student_deductions.change_deduction_value_interactive(
106
+ max_points=selected_item.max_points
107
+ )
108
+ elif txt == MenuCommand.EXIT.value:
109
+ print_color(TermColors.BLUE, "Exiting grader")
110
+ sys.exit(0)
111
+ else:
112
+ print_color(TermColors.YELLOW, "Invalid option. Try again.")
113
+
114
+
115
+ def _select_item_interactive(items, action_description):
116
+ """Prompt user to select an item when there are multiple items.
117
+
118
+ Args:
119
+ items: List of GradeItem objects
120
+ action_description: String describing the action (e.g., "delete deduction type from")
121
+
122
+ Returns:
123
+ Selected GradeItem, or None if cancelled
124
+ """
125
+ if len(items) == 1:
126
+ return items[0]
127
+
128
+ print(f"\nSelect which item to {action_description}:")
129
+ for i, item in enumerate(items, 1):
130
+ print(f" [{i}] {item.item_name}")
131
+ print(" [Enter] Cancel")
132
+
133
+ txt = input("\n>>> ").strip()
134
+ if txt == "":
135
+ return None
136
+
137
+ try:
138
+ idx = int(txt) - 1
139
+ if 0 <= idx < len(items):
140
+ return items[idx]
141
+ print_color(TermColors.YELLOW, "Invalid selection.")
142
+ return None
143
+ except ValueError:
144
+ print_color(TermColors.YELLOW, "Invalid input.")
145
+ return None
146
+
147
+
148
+ def get_score(
149
+ names,
150
+ max_points,
151
+ *,
152
+ allow_rebuild=True,
153
+ allow_rerun=True,
154
+ student_deductions,
155
+ net_ids,
156
+ last_graded_net_ids=None,
157
+ names_by_netid=None,
158
+ all_items=None,
159
+ ):
160
+ """Prompts the user for a score for the grade column.
161
+
162
+ Args:
163
+ names: Student name(s) to display
164
+ max_points: Maximum points for this item
165
+ allow_rebuild: Whether rebuild option is available
166
+ allow_rerun: Whether rerun option is available
167
+ student_deductions: StudentDeductions object
168
+ net_ids: Tuple of net_ids for the current student
169
+ last_graded_net_ids: Tuple of net_ids for the last graded student (for undo)
170
+ names_by_netid: Dict mapping net_id -> (first_name, last_name) for search
171
+ all_items: List of all GradeItem objects (for multi-item operations like [g], [d], [v])
172
+
173
+ Returns:
174
+ Either a numeric score (float) or a ScoreResult enum value
175
+ """
176
+ fpad2 = " " * 4
177
+ pad = 10
178
+
179
+ while True:
180
+ # Compute current score
181
+ deductions_total = student_deductions.total_deductions(tuple(net_ids))
182
+ computed_score = max(0, max_points - deductions_total)
183
+
184
+ print("")
185
+ print("-" * 60)
186
+
187
+ # Show current deductions for this student
188
+ current_deductions = student_deductions.get_student_deductions(tuple(net_ids))
189
+ print(
190
+ fpad2 + f"Current score: {TermColors.GREEN}{computed_score}{TermColors.END}"
191
+ )
192
+ print(fpad2 + "Current deductions:")
193
+ if current_deductions:
194
+ for d in current_deductions:
195
+ print(fpad2 + f" -{d.points}: {d.message}")
196
+ else:
197
+ print(fpad2 + " (None)")
198
+ print("")
199
+
200
+ ################### Build input menu #######################
201
+ # Build menu items for two-column display
202
+ # Each item is (MenuCommand or None, description) - None for special items like [Enter]
203
+ left_items = [
204
+ (MenuCommand.SKIP, "Skip student"),
205
+ ]
206
+ if allow_rebuild:
207
+ left_items.append((MenuCommand.BUILD, "Build & run"))
208
+ if allow_rerun:
209
+ desc = "Re-run" if not allow_rebuild else "Re-run (no build)"
210
+ left_items.append((MenuCommand.RERUN, desc))
211
+ left_items.append((MenuCommand.MANAGE_GRADES, "Manage grades"))
212
+ if last_graded_net_ids is not None:
213
+ left_items.append((MenuCommand.UNDO, f"Undo last ({last_graded_net_ids[0]})"))
214
+ left_items.append((MenuCommand.EXIT, "Exit grader"))
215
+
216
+ right_items = [
217
+ (MenuCommand.NEW_DEDUCTION, "New deduction"),
218
+ (MenuCommand.DELETE_DEDUCTION, "Delete deduction"),
219
+ (MenuCommand.CLEAR_DEDUCTIONS, "Clear deductions"),
220
+ (MenuCommand.CHANGE_VALUE, "Change deduction value"),
221
+ (None, "Accept score"), # [Enter] - special case
222
+ ]
223
+
224
+ # Build allowed_cmds from menu items
225
+ allowed_cmds = {}
226
+ for cmd, _ in left_items + right_items:
227
+ if cmd is not None:
228
+ allowed_cmds[cmd.value] = cmd
229
+
230
+ # Format menu items in two columns
231
+ col_width = 38 # Each column width (2 columns * 38 = 76 < 80)
232
+ input_txt = (
233
+ TermColors.BLUE + "Enter a grade for " + names + ":" + TermColors.END + "\n"
234
+ )
235
+
236
+ # Combine left and right items row by row
237
+ max_rows = max(len(left_items), len(right_items))
238
+ for i in range(max_rows):
239
+ # Left column
240
+ if i < len(left_items):
241
+ cmd, desc = left_items[i]
242
+ key_str = cmd.key_display if cmd else "[Enter]"
243
+ col1 = (
244
+ fpad2 + TermColors.BLUE + key_str.ljust(pad) + TermColors.END + desc
245
+ )
246
+ # Pad to column width (accounting for ANSI codes)
247
+ col1_visible_len = len(fpad2) + len(key_str.ljust(pad)) + len(desc)
248
+ col1_padded = col1 + " " * (col_width - col1_visible_len)
249
+ else:
250
+ col1_padded = " " * col_width
251
+
252
+ # Right column
253
+ if i < len(right_items):
254
+ cmd, desc = right_items[i]
255
+ key_str = cmd.key_display if cmd else "[Enter]"
256
+ col2 = TermColors.BLUE + key_str.ljust(pad) + TermColors.END + desc
257
+ input_txt += col1_padded + col2 + "\n"
258
+ else:
259
+ input_txt += col1_padded.rstrip() + "\n"
260
+
261
+ # Show available deduction types to apply
262
+ input_txt += fpad2 + "Apply deduction(s):\n"
263
+ for (
264
+ deduction_id,
265
+ deduction_type,
266
+ ) in student_deductions.deduction_types.items():
267
+ input_txt += (
268
+ fpad2
269
+ + TermColors.BLUE
270
+ + f" [{deduction_id}]".ljust(pad)
271
+ + TermColors.END
272
+ + f"-{deduction_type.points}: {deduction_type.message}\n"
273
+ )
274
+
275
+ input_txt += TermColors.BLUE + ">>> " + TermColors.END
276
+
277
+ ################### Get and handle user input #######################
278
+ txt = input(input_txt)
279
+
280
+ # Check for Enter key to accept computed score
281
+ if txt == "":
282
+ print(f"Saving score: {computed_score}")
283
+ return computed_score
284
+
285
+ # Check for commands
286
+ if txt in allowed_cmds:
287
+ cmd = allowed_cmds[txt]
288
+
289
+ # Handle special cases that need to loop back
290
+ if cmd == MenuCommand.NEW_DEDUCTION:
291
+ deduction_id = student_deductions.create_deduction_type_interactive(
292
+ max_points=max_points
293
+ )
294
+ if deduction_id >= 0:
295
+ # Auto-apply the new deduction to this student
296
+ student_deductions.apply_deduction_to_student(
297
+ tuple(net_ids), deduction_id
298
+ )
299
+ continue
300
+ if cmd == MenuCommand.DELETE_DEDUCTION:
301
+ if all_items and len(all_items) > 1:
302
+ selected_item = _select_item_interactive(
303
+ all_items, "delete deduction type from"
304
+ )
305
+ if selected_item:
306
+ selected_item.student_deductions.delete_deduction_type_interactive()
307
+ else:
308
+ student_deductions.delete_deduction_type_interactive()
309
+ continue
310
+ if cmd == MenuCommand.CHANGE_VALUE:
311
+ if all_items and len(all_items) > 1:
312
+ selected_item = _select_item_interactive(
313
+ all_items, "change deduction value for"
314
+ )
315
+ if selected_item:
316
+ selected_item.student_deductions.change_deduction_value_interactive(
317
+ max_points=selected_item.max_points
318
+ )
319
+ else:
320
+ student_deductions.change_deduction_value_interactive(
321
+ max_points=max_points
322
+ )
323
+ continue
324
+ if cmd == MenuCommand.MANAGE_GRADES:
325
+ if all_items:
326
+ _manage_grades_interactive(all_items, names_by_netid)
327
+ else:
328
+ # Fallback for single item - wrap in list
329
+ # Create a minimal item-like object for backwards compatibility
330
+ class _SingleItemWrapper:
331
+ def __init__(self, sd):
332
+ self.student_deductions = sd
333
+
334
+ _manage_grades_interactive(
335
+ [_SingleItemWrapper(student_deductions)], names_by_netid
336
+ )
337
+ continue
338
+
339
+ # Commands that return directly (SKIP, BUILD, RERUN, UNDO, EXIT)
340
+ return cmd
341
+
342
+ # Check for clear command (must be exactly "0", not in a list)
343
+ if txt == MenuCommand.CLEAR_DEDUCTIONS.value:
344
+ student_deductions.clear_student_deductions(tuple(net_ids))
345
+ print("Cleared all deductions for this student.")
346
+ continue
347
+
348
+ # Check for deduction ID input
349
+ # Supports comma-separated list of deduction IDs (e.g., "1,2,3")
350
+ parts = [p.strip() for p in txt.split(",")]
351
+ valid_ids = []
352
+ all_valid = True
353
+ for part in parts:
354
+ try:
355
+ deduction_id = int(part)
356
+ # Don't allow 0 or other special commands in the list
357
+ if deduction_id <= 0:
358
+ all_valid = False
359
+ break
360
+ if deduction_id in student_deductions.deduction_types:
361
+ valid_ids.append(deduction_id)
362
+ else:
363
+ all_valid = False
364
+ break
365
+ except ValueError:
366
+ all_valid = False
367
+ break
368
+
369
+ if all_valid and valid_ids:
370
+ # Apply the deductions
371
+ for deduction_id in valid_ids:
372
+ student_deductions.apply_deduction_to_student(
373
+ tuple(net_ids), deduction_id
374
+ )
375
+ deduction_type = student_deductions.deduction_types[deduction_id]
376
+ print(
377
+ f"Applied deduction: {deduction_type.message} (-{deduction_type.points})"
378
+ )
379
+ continue
380
+
381
+ print_color(TermColors.YELLOW, "Invalid input. Try again.")
382
+
383
+
384
+ def _manage_grades_interactive(all_items, names_by_netid=None):
385
+ """Interactive menu to manage (view/delete) grades for any student.
386
+
387
+ Args:
388
+ all_items: List of GradeItem objects (to access their student_deductions)
389
+ names_by_netid: Dict mapping net_id -> (first_name, last_name) for search
390
+ """
391
+ while True:
392
+ # Collect graded students from ALL items
393
+ all_graded_students = set()
394
+ for item in all_items:
395
+ for student_key in item.student_deductions.get_graded_students():
396
+ all_graded_students.add(student_key)
397
+
398
+ print("\n" + "=" * 60)
399
+ print_color(TermColors.BLUE, "Manage Grades")
400
+ print("=" * 60)
401
+
402
+ if not all_graded_students:
403
+ print("No students have been graded yet.")
404
+ input("Press Enter to continue...")
405
+ return
406
+
407
+ print(f"{len(all_graded_students)} student(s) graded.")
408
+ print("\nOptions:")
409
+ print(" [search] Enter search string to find student by name or net_id")
410
+ print(" [*] List all graded students")
411
+ print(" [Enter] Return to grading")
412
+
413
+ txt = input("\nSearch: ").strip()
414
+
415
+ if txt == "":
416
+ return
417
+
418
+ # Check for wildcard to list all
419
+ list_all = txt == "*"
420
+
421
+ # Search for matching students (case-insensitive)
422
+ search_lower = txt.lower()
423
+ matches = []
424
+
425
+ for net_ids in all_graded_students:
426
+ match_found = list_all # If listing all, match everything
427
+ display_parts = []
428
+
429
+ for net_id in net_ids:
430
+ # Check if search matches net_id (skip if listing all)
431
+ if not list_all and search_lower in net_id.lower():
432
+ match_found = True
433
+
434
+ # Check if search matches first/last name
435
+ if names_by_netid and net_id in names_by_netid:
436
+ first_name, last_name = names_by_netid[net_id]
437
+ if not list_all and (
438
+ search_lower in first_name.lower()
439
+ or search_lower in last_name.lower()
440
+ ):
441
+ match_found = True
442
+ display_parts.append(f"{first_name} {last_name} ({net_id})")
443
+ else:
444
+ display_parts.append(net_id)
445
+
446
+ if match_found:
447
+ matches.append((net_ids, ", ".join(display_parts)))
448
+
449
+ if not matches:
450
+ print_color(TermColors.YELLOW, f"No graded students match '{txt}'")
451
+ continue
452
+
453
+ # Sort matches by display name
454
+ matches.sort(key=lambda x: x[1].lower())
455
+
456
+ # Display matches and let user pick one
457
+ # Show combined deduction info from all items
458
+ print(f"\nFound {len(matches)} match(es):")
459
+ for i, (net_ids, display) in enumerate(matches, 1):
460
+ total_deductions = 0
461
+ total_items_graded = 0
462
+ total_deduction_count = 0
463
+ for item in all_items:
464
+ if item.student_deductions.is_student_graded(net_ids):
465
+ total_items_graded += 1
466
+ total_deductions += item.student_deductions.total_deductions(
467
+ net_ids
468
+ )
469
+ total_deduction_count += len(
470
+ item.student_deductions.get_student_deductions(net_ids)
471
+ )
472
+ # Format differently based on single vs multiple items
473
+ if len(all_items) == 1:
474
+ print(
475
+ f" [{i}] {display} "
476
+ f"({total_deduction_count} deduction(s), -{total_deductions} pts)"
477
+ )
478
+ else:
479
+ print(
480
+ f" [{i}] {display} "
481
+ f"(graded in {total_items_graded} item(s), -{total_deductions} pts total)"
482
+ )
483
+
484
+ print("\n [#] Enter number to delete that student's grade")
485
+ print(" [Enter] Cancel")
486
+
487
+ choice = input("\n>>> ").strip()
488
+
489
+ if choice == "":
490
+ continue
491
+
492
+ try:
493
+ idx = int(choice) - 1
494
+ if 0 <= idx < len(matches):
495
+ student_key, display = matches[idx]
496
+ # Confirm deletion
497
+ confirm = (
498
+ input(
499
+ f"Delete grade for {display} from ALL items? This cannot be undone. [y/N]: "
500
+ )
501
+ .strip()
502
+ .lower()
503
+ )
504
+
505
+ if confirm == "y":
506
+ # Delete from ALL items
507
+ for item in all_items:
508
+ item.student_deductions.clear_student_deductions(student_key)
509
+ print_color(
510
+ TermColors.GREEN, f"Deleted grade for {display} from all items"
511
+ )
512
+ else:
513
+ print("Cancelled.")
514
+ else:
515
+ print_color(TermColors.YELLOW, "Invalid selection.")
516
+ except ValueError:
517
+ print_color(TermColors.YELLOW, "Invalid input.")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ygrader
3
- Version: 2.6.5
3
+ Version: 2.6.7
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
@@ -1,343 +0,0 @@
1
- """Module for prompting users for scores during grading."""
2
-
3
- from enum import Enum, auto
4
-
5
- from .utils import TermColors, print_color
6
-
7
-
8
- class ScoreResult(Enum):
9
- """Enum representing special score input results."""
10
-
11
- SKIP = auto()
12
- REBUILD = auto()
13
- RERUN = auto()
14
- CREATE_DEDUCTION = auto()
15
- UNDO_LAST = auto()
16
- EXIT = auto()
17
-
18
-
19
- def get_score(
20
- names,
21
- max_points,
22
- *,
23
- allow_rebuild=True,
24
- allow_rerun=True,
25
- student_deductions,
26
- net_ids,
27
- last_graded_net_ids=None,
28
- names_by_netid=None,
29
- ):
30
- """Prompts the user for a score for the grade column.
31
-
32
- Args:
33
- names: Student name(s) to display
34
- max_points: Maximum points for this item
35
- allow_rebuild: Whether rebuild option is available
36
- allow_rerun: Whether rerun option is available
37
- student_deductions: StudentDeductions object
38
- net_ids: Tuple of net_ids for the current student
39
- last_graded_net_ids: Tuple of net_ids for the last graded student (for undo)
40
- names_by_netid: Dict mapping net_id -> (first_name, last_name) for search
41
-
42
- Returns:
43
- Either a numeric score (float) or a ScoreResult enum value
44
- """
45
- fpad2 = " " * 4
46
- pad = 10
47
-
48
- while True:
49
- # Compute current score
50
- deductions_total = student_deductions.total_deductions(tuple(net_ids))
51
- computed_score = max(0, max_points - deductions_total)
52
-
53
- print("")
54
- print("-" * 60)
55
-
56
- # Show current deductions for this student
57
- current_deductions = student_deductions.get_student_deductions(tuple(net_ids))
58
- print(
59
- fpad2 + f"Current score: {TermColors.GREEN}{computed_score}{TermColors.END}"
60
- )
61
- print(fpad2 + "Current deductions:")
62
- if current_deductions:
63
- for d in current_deductions:
64
- print(fpad2 + f" -{d.points}: {d.message}")
65
- else:
66
- print(fpad2 + " (None)")
67
- print("")
68
-
69
- ################### Build input menu #######################
70
- # Build menu items for two-column display (left_items, right_items)
71
- left_items = []
72
- right_items = []
73
- allowed_cmds = {}
74
-
75
- left_items.append(("[s]", "Skip student"))
76
- allowed_cmds["s"] = ScoreResult.SKIP
77
-
78
- if allow_rebuild:
79
- left_items.append(("[b]", "Build & run"))
80
- allowed_cmds["b"] = ScoreResult.REBUILD
81
- if allow_rerun:
82
- desc = "Re-run" if not allow_rebuild else "Re-run (no build)"
83
- left_items.append(("[r]", desc))
84
- allowed_cmds["r"] = ScoreResult.RERUN
85
-
86
- # Add deduction options to right column
87
- right_items.append(("[n]", "New deduction"))
88
- allowed_cmds["n"] = "create"
89
-
90
- right_items.append(("[d]", "Delete deduction"))
91
- allowed_cmds["d"] = "delete"
92
-
93
- right_items.append(("[0]", "Clear deductions"))
94
-
95
- right_items.append(("[v]", "Change deduction value"))
96
- allowed_cmds["v"] = "change_value"
97
-
98
- # Accept score at the bottom of right column
99
- right_items.append(("[Enter]", "Accept score"))
100
-
101
- # Add manage grades and undo to left column
102
- left_items.append(("[g]", "Manage grades"))
103
- allowed_cmds["g"] = "manage"
104
-
105
- # Add undo option if there's a last graded student
106
- if last_graded_net_ids is not None:
107
- left_items.append(("[u]", f"Undo last ({last_graded_net_ids[0]})"))
108
- allowed_cmds["u"] = ScoreResult.UNDO_LAST
109
-
110
- # Add exit option at bottom of left column
111
- left_items.append(("[e]", "Exit grader"))
112
- allowed_cmds["e"] = ScoreResult.EXIT
113
-
114
- # Format menu items in two columns
115
- col_width = 38 # Each column width (2 columns * 38 = 76 < 80)
116
- input_txt = (
117
- TermColors.BLUE + "Enter a grade for " + names + ":" + TermColors.END + "\n"
118
- )
119
-
120
- # Combine left and right items row by row
121
- max_rows = max(len(left_items), len(right_items))
122
- for i in range(max_rows):
123
- # Left column
124
- if i < len(left_items):
125
- key1, desc1 = left_items[i]
126
- col1 = (
127
- fpad2 + TermColors.BLUE + key1.ljust(pad) + TermColors.END + desc1
128
- )
129
- # Pad to column width (accounting for ANSI codes)
130
- col1_visible_len = len(fpad2) + len(key1.ljust(pad)) + len(desc1)
131
- col1_padded = col1 + " " * (col_width - col1_visible_len)
132
- else:
133
- col1_padded = " " * col_width
134
-
135
- # Right column
136
- if i < len(right_items):
137
- key2, desc2 = right_items[i]
138
- col2 = TermColors.BLUE + key2.ljust(pad) + TermColors.END + desc2
139
- input_txt += col1_padded + col2 + "\n"
140
- else:
141
- input_txt += col1_padded.rstrip() + "\n"
142
-
143
- # Show available deduction types to apply
144
- input_txt += fpad2 + "Apply deduction(s):\n"
145
- for (
146
- deduction_id,
147
- deduction_type,
148
- ) in student_deductions.deduction_types.items():
149
- input_txt += (
150
- fpad2
151
- + TermColors.BLUE
152
- + f" [{deduction_id}]".ljust(pad)
153
- + TermColors.END
154
- + f"-{deduction_type.points}: {deduction_type.message}\n"
155
- )
156
-
157
- input_txt += TermColors.BLUE + ">>> " + TermColors.END
158
-
159
- ################### Get and handle user input #######################
160
- txt = input(input_txt)
161
-
162
- # Check for Enter key to accept computed score
163
- if txt == "":
164
- print(f"Saving score: {computed_score}")
165
- return computed_score
166
-
167
- # Check for commands
168
- if txt in allowed_cmds:
169
- result = allowed_cmds[txt]
170
-
171
- # Handle special cases that need to loop back
172
- if result == "create":
173
- deduction_id = student_deductions.create_deduction_type_interactive(
174
- max_points=max_points
175
- )
176
- if deduction_id >= 0:
177
- # Auto-apply the new deduction to this student
178
- student_deductions.apply_deduction_to_student(
179
- tuple(net_ids), deduction_id
180
- )
181
- continue
182
- if result == "delete":
183
- student_deductions.delete_deduction_type_interactive()
184
- continue
185
- if result == "change_value":
186
- student_deductions.change_deduction_value_interactive(
187
- max_points=max_points
188
- )
189
- continue
190
- if result == "manage":
191
- _manage_grades_interactive(student_deductions, names_by_netid)
192
- continue
193
- return result
194
-
195
- # Check for clear command (must be exactly "0", not in a list)
196
- if txt == "0":
197
- student_deductions.clear_student_deductions(tuple(net_ids))
198
- print("Cleared all deductions for this student.")
199
- continue
200
-
201
- # Check for deduction ID input
202
- # Supports comma-separated list of deduction IDs (e.g., "1,2,3")
203
- parts = [p.strip() for p in txt.split(",")]
204
- valid_ids = []
205
- all_valid = True
206
- for part in parts:
207
- try:
208
- deduction_id = int(part)
209
- # Don't allow 0 or other special commands in the list
210
- if deduction_id <= 0:
211
- all_valid = False
212
- break
213
- if deduction_id in student_deductions.deduction_types:
214
- valid_ids.append(deduction_id)
215
- else:
216
- all_valid = False
217
- break
218
- except ValueError:
219
- all_valid = False
220
- break
221
-
222
- if all_valid and valid_ids:
223
- # Apply the deductions
224
- for deduction_id in valid_ids:
225
- student_deductions.apply_deduction_to_student(
226
- tuple(net_ids), deduction_id
227
- )
228
- deduction_type = student_deductions.deduction_types[deduction_id]
229
- print(
230
- f"Applied deduction: {deduction_type.message} (-{deduction_type.points})"
231
- )
232
- continue
233
-
234
- print_color(TermColors.YELLOW, "Invalid input. Try again.")
235
-
236
-
237
- def _manage_grades_interactive(student_deductions, names_by_netid=None):
238
- """Interactive menu to manage (view/delete) grades for any student.
239
-
240
- Args:
241
- student_deductions: StudentDeductions object to manage
242
- names_by_netid: Dict mapping net_id -> (first_name, last_name) for search
243
- """
244
- while True:
245
- graded_students = student_deductions.get_graded_students()
246
-
247
- print("\n" + "=" * 60)
248
- print_color(TermColors.BLUE, "Manage Grades")
249
- print("=" * 60)
250
-
251
- if not graded_students:
252
- print("No students have been graded yet.")
253
- input("Press Enter to continue...")
254
- return
255
-
256
- print(f"{len(graded_students)} student(s) graded.")
257
- print("\nOptions:")
258
- print(" [search] Enter search string to find student by name or net_id")
259
- print(" [*] List all graded students")
260
- print(" [Enter] Return to grading")
261
-
262
- txt = input("\nSearch: ").strip()
263
-
264
- if txt == "":
265
- return
266
-
267
- # Check for wildcard to list all
268
- list_all = txt == "*"
269
-
270
- # Search for matching students (case-insensitive)
271
- search_lower = txt.lower()
272
- matches = []
273
-
274
- for net_ids in graded_students:
275
- match_found = list_all # If listing all, match everything
276
- display_parts = []
277
-
278
- for net_id in net_ids:
279
- # Check if search matches net_id (skip if listing all)
280
- if not list_all and search_lower in net_id.lower():
281
- match_found = True
282
-
283
- # Check if search matches first/last name
284
- if names_by_netid and net_id in names_by_netid:
285
- first_name, last_name = names_by_netid[net_id]
286
- if not list_all and (
287
- search_lower in first_name.lower()
288
- or search_lower in last_name.lower()
289
- ):
290
- match_found = True
291
- display_parts.append(f"{first_name} {last_name} ({net_id})")
292
- else:
293
- display_parts.append(net_id)
294
-
295
- if match_found:
296
- matches.append((net_ids, ", ".join(display_parts)))
297
-
298
- if not matches:
299
- print_color(TermColors.YELLOW, f"No graded students match '{txt}'")
300
- continue
301
-
302
- # Sort matches by display name
303
- matches.sort(key=lambda x: x[1].lower())
304
-
305
- # Display matches and let user pick one
306
- print(f"\nFound {len(matches)} match(es):")
307
- for i, (net_ids, display) in enumerate(matches, 1):
308
- deductions = student_deductions.get_student_deductions(net_ids)
309
- deduction_count = len(deductions)
310
- total_deducted = student_deductions.total_deductions(net_ids)
311
- print(
312
- f" [{i}] {display} "
313
- f"({deduction_count} deduction(s), -{total_deducted} pts)"
314
- )
315
-
316
- print("\n [#] Enter number to delete that student's grade")
317
- print(" [Enter] Cancel")
318
-
319
- choice = input("\n>>> ").strip()
320
-
321
- if choice == "":
322
- continue
323
-
324
- try:
325
- idx = int(choice) - 1
326
- if 0 <= idx < len(matches):
327
- student_key, display = matches[idx]
328
- # Confirm deletion
329
- confirm = (
330
- input(f"Delete grade for {display}? This cannot be undone. [y/N]: ")
331
- .strip()
332
- .lower()
333
- )
334
-
335
- if confirm == "y":
336
- student_deductions.clear_student_deductions(student_key)
337
- print_color(TermColors.GREEN, f"Deleted grade for {display}")
338
- else:
339
- print("Cancelled.")
340
- else:
341
- print_color(TermColors.YELLOW, "Invalid selection.")
342
- except ValueError:
343
- print_color(TermColors.YELLOW, "Invalid input.")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes