ygrader 2.2.0__tar.gz → 2.4.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.2.0/ygrader.egg-info → ygrader-2.4.0}/PKG-INFO +1 -1
  2. {ygrader-2.2.0 → ygrader-2.4.0}/setup.py +1 -1
  3. {ygrader-2.2.0 → ygrader-2.4.0}/ygrader/deductions.py +24 -0
  4. {ygrader-2.2.0 → ygrader-2.4.0}/ygrader/feedback.py +64 -2
  5. {ygrader-2.2.0 → ygrader-2.4.0}/ygrader/grader.py +30 -2
  6. {ygrader-2.2.0 → ygrader-2.4.0}/ygrader/grading_item.py +49 -4
  7. ygrader-2.4.0/ygrader/score_input.py +324 -0
  8. {ygrader-2.2.0 → ygrader-2.4.0/ygrader.egg-info}/PKG-INFO +1 -1
  9. ygrader-2.2.0/ygrader/score_input.py +0 -231
  10. {ygrader-2.2.0 → ygrader-2.4.0}/LICENSE +0 -0
  11. {ygrader-2.2.0 → ygrader-2.4.0}/setup.cfg +0 -0
  12. {ygrader-2.2.0 → ygrader-2.4.0}/test/test_interactive.py +0 -0
  13. {ygrader-2.2.0 → ygrader-2.4.0}/test/test_unittest.py +0 -0
  14. {ygrader-2.2.0 → ygrader-2.4.0}/ygrader/__init__.py +0 -0
  15. {ygrader-2.2.0 → ygrader-2.4.0}/ygrader/grades_csv.py +0 -0
  16. {ygrader-2.2.0 → ygrader-2.4.0}/ygrader/grading_item_config.py +0 -0
  17. {ygrader-2.2.0 → ygrader-2.4.0}/ygrader/send_ctrl_backtick.ahk +0 -0
  18. {ygrader-2.2.0 → ygrader-2.4.0}/ygrader/student_repos.py +0 -0
  19. {ygrader-2.2.0 → ygrader-2.4.0}/ygrader/upstream_merger.py +0 -0
  20. {ygrader-2.2.0 → ygrader-2.4.0}/ygrader/utils.py +0 -0
  21. {ygrader-2.2.0 → ygrader-2.4.0}/ygrader.egg-info/SOURCES.txt +0 -0
  22. {ygrader-2.2.0 → ygrader-2.4.0}/ygrader.egg-info/dependency_links.txt +0 -0
  23. {ygrader-2.2.0 → ygrader-2.4.0}/ygrader.egg-info/requires.txt +0 -0
  24. {ygrader-2.2.0 → ygrader-2.4.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.2.0
3
+ Version: 2.4.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.2.0",
7
+ version="2.4.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",
@@ -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
@@ -7,6 +7,7 @@ from typing import Callable, Dict, Optional, Tuple
7
7
 
8
8
  import numpy as np
9
9
  import pandas
10
+ import yaml
10
11
 
11
12
  from .deductions import StudentDeductions
12
13
  from .grading_item_config import LearningSuiteColumn
@@ -17,6 +18,36 @@ from .utils import warning, print_color, TermColors
17
18
  LatePenaltyCallback = Callable[[int, float, float], float]
18
19
 
19
20
 
21
+ def _load_due_date_exceptions(
22
+ exceptions_path: pathlib.Path,
23
+ ) -> Dict[str, datetime.datetime]:
24
+ """Load due date exceptions from YAML file.
25
+
26
+ Args:
27
+ exceptions_path: Path to the deadline_exceptions.yaml file.
28
+ Expected format is: net_id: "YYYY-MM-DD HH:MM:SS"
29
+
30
+ Returns:
31
+ Mapping from net_id to exception datetime.
32
+ """
33
+ if not exceptions_path.exists():
34
+ return {}
35
+
36
+ with open(exceptions_path, "r", encoding="utf-8") as f:
37
+ data = yaml.safe_load(f)
38
+
39
+ if not data:
40
+ return {}
41
+
42
+ exceptions = {}
43
+ for net_id, exception_date in data.items():
44
+ if net_id and exception_date:
45
+ exceptions[net_id] = datetime.datetime.strptime(
46
+ exception_date, "%Y-%m-%d %H:%M:%S"
47
+ )
48
+ return exceptions
49
+
50
+
20
51
  def _calculate_late_days(
21
52
  submit_time_str: Optional[str],
22
53
  due_date: datetime.datetime,
@@ -188,7 +219,7 @@ def assemble_grades(
188
219
  output_csv_path: Optional[pathlib.Path] = None,
189
220
  late_penalty_callback: Optional[LatePenaltyCallback] = None,
190
221
  due_date: Optional[datetime.datetime] = None,
191
- due_date_exceptions: Optional[Dict[str, datetime.datetime]] = None,
222
+ due_date_exceptions_path: Optional[pathlib.Path] = None,
192
223
  ) -> Tuple[Optional[pathlib.Path], Optional[pathlib.Path]]:
193
224
  """Generate feedback zip and/or grades CSV from deductions.
194
225
 
@@ -201,12 +232,17 @@ def assemble_grades(
201
232
  late_penalty_callback: Optional callback function that takes
202
233
  (late_days, max_score, actual_score) and returns the adjusted score.
203
234
  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.
235
+ due_date_exceptions_path: Path to YAML file with due date exceptions (net_id: "YYYY-MM-DD HH:MM:SS").
205
236
 
206
237
  Returns:
207
238
  Tuple of (feedback_zip_path or None, grades_csv_path or None).
208
239
  """
209
240
  yaml_path = pathlib.Path(yaml_path)
241
+
242
+ # Load due date exceptions if path provided
243
+ due_date_exceptions: Dict[str, datetime.datetime] = {}
244
+ if due_date_exceptions_path:
245
+ due_date_exceptions = _load_due_date_exceptions(due_date_exceptions_path)
210
246
  ls_column = LearningSuiteColumn(yaml_path)
211
247
 
212
248
  # Get the lab name from the YAML file's parent directory
@@ -237,6 +273,32 @@ def assemble_grades(
237
273
  last_name = str(student_row["Last Name"]).strip()
238
274
  net_id = str(student_row["Net ID"]).strip()
239
275
 
276
+ # Check for partial grades (graded for some items but not others)
277
+ items_graded = []
278
+ items_not_graded = []
279
+ for item in ls_column.items:
280
+ deductions_obj = subitem_deductions.get(item.name)
281
+ if deductions_obj and deductions_obj.is_student_graded((net_id,)):
282
+ items_graded.append(item.name)
283
+ else:
284
+ # Also check for multi-student keys containing this net_id
285
+ found = False
286
+ if deductions_obj:
287
+ for key in deductions_obj.deductions_by_students.keys():
288
+ if net_id in key:
289
+ items_graded.append(item.name)
290
+ found = True
291
+ break
292
+ if not found:
293
+ items_not_graded.append(item.name)
294
+
295
+ if items_graded and items_not_graded:
296
+ print_color(
297
+ TermColors.YELLOW,
298
+ f"Partial grade: {net_id} graded for [{', '.join(items_graded)}] "
299
+ f"but NOT [{', '.join(items_not_graded)}]",
300
+ )
301
+
240
302
  # Calculate score before late penalty
241
303
  score_before_late, total_possible, max_late_days = _calculate_student_score(
242
304
  net_id=net_id,
@@ -459,8 +459,20 @@ class Grader:
459
459
  self._run_grading(student_grades_df, grouped_df)
460
460
 
461
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
+
462
473
  # Loop through all of the students/groups and perform grading
463
- for _, row in grouped_df.iterrows():
474
+ while idx < len(rows_list):
475
+ _, row = rows_list[idx]
464
476
  first_names = grades_csv.get_first_names(row)
465
477
  last_names = grades_csv.get_last_names(row)
466
478
  net_ids = grades_csv.get_net_ids(row)
@@ -473,6 +485,7 @@ class Grader:
473
485
 
474
486
  if sum(num_group_members_need_grade_per_item) == 0:
475
487
  # This student/group is already fully graded
488
+ idx += 1
476
489
  continue
477
490
 
478
491
  # Print name(s) of who we are grading
@@ -491,6 +504,7 @@ class Grader:
491
504
  # Code from zip will return modified time (epoch, float). Code from github will return True.
492
505
  success = self._get_student_code(row, student_work_path)
493
506
  if not success:
507
+ idx += 1
494
508
  continue
495
509
 
496
510
  # Format student code
@@ -518,13 +532,23 @@ class Grader:
518
532
  )
519
533
  except CallbackFailed as e:
520
534
  print_color(TermColors.RED, repr(e))
535
+ idx += 1
521
536
  continue
522
537
  except KeyboardInterrupt:
523
538
  pass
524
539
 
525
540
  # Loop through all items that are to be graded
541
+ go_back = False
526
542
  for item in self.items:
527
- 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
528
552
 
529
553
  if self.dry_run_first:
530
554
  print_color(
@@ -533,6 +557,10 @@ class Grader:
533
557
  )
534
558
  break
535
559
 
560
+ # Move to next student and remember this one for potential undo
561
+ prev_idx = idx
562
+ idx += 1
563
+
536
564
  def _unzip_submissions(self):
537
565
  with zipfile.ZipFile(self.learning_suite_submissions_zip_path, "r") as f:
538
566
  for zip_info in f.infolist():
@@ -36,9 +36,34 @@ 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
+ self.names_by_netid = self._build_names_lookup() # net_id -> (first_name, last_name)
41
+
42
+ def _build_names_lookup(self):
43
+ """Build a lookup dictionary from net_id to (first_name, last_name) from the class list CSV."""
44
+ # Import pandas here to avoid circular import and since it's already imported in grader.py
45
+ import pandas # pylint: disable=import-outside-toplevel
46
+ names_by_netid = {}
47
+ try:
48
+ df = pandas.read_csv(self.grader.class_list_csv_path)
49
+ for _, row in df.iterrows():
50
+ if "Net ID" in row and "First Name" in row and "Last Name" in row:
51
+ net_id = row["Net ID"]
52
+ first_name = row["First Name"]
53
+ last_name = row["Last Name"]
54
+ if pandas.notna(net_id) and pandas.notna(first_name) and pandas.notna(last_name):
55
+ names_by_netid[net_id] = (first_name, last_name)
56
+ except (FileNotFoundError, pandas.errors.EmptyDataError, KeyError):
57
+ pass # If we can't read the CSV, just use an empty dict
58
+ return names_by_netid
39
59
 
40
60
  def run_grading(self, _student_grades_df, row, callback_args):
41
- """Run the grading process for this item"""
61
+ """Run the grading process for this item.
62
+
63
+ Returns:
64
+ True if user requested to go back and regrade the previous student,
65
+ False otherwise.
66
+ """
42
67
  net_ids = grades_csv.get_net_ids(row)
43
68
  num_group_members = len(net_ids)
44
69
  concated_names = grades_csv.get_concated_names(row)
@@ -63,7 +88,7 @@ class GradeItem:
63
88
  "Grade already exists for this item",
64
89
  "(skipping)",
65
90
  )
66
- return
91
+ return False
67
92
 
68
93
  while True:
69
94
  print_color(
@@ -219,26 +244,46 @@ class GradeItem:
219
244
  allow_rerun=self.grader.allow_rerun,
220
245
  student_deductions=self.student_deductions,
221
246
  net_ids=tuple(net_ids),
247
+ last_graded_net_ids=self.last_graded_net_ids,
248
+ names_by_netid=self.names_by_netid,
222
249
  )
223
250
  except KeyboardInterrupt:
224
251
  print_color(TermColors.RED, "\nExiting")
225
252
  sys.exit(0)
226
253
 
227
254
  if score == ScoreResult.SKIP:
228
- break
255
+ return False
229
256
  if score == ScoreResult.REBUILD:
230
257
  continue
231
258
  if score == ScoreResult.RERUN:
232
259
  # run again, but don't build
233
260
  build = False
234
261
  continue
262
+ if score == ScoreResult.UNDO_LAST:
263
+ # Undo the last graded student and signal to go back
264
+ if self.last_graded_net_ids is not None:
265
+ self.student_deductions.clear_student_deductions(
266
+ self.last_graded_net_ids
267
+ )
268
+ print_color(
269
+ TermColors.GREEN,
270
+ f"Undid grade for {', '.join(self.last_graded_net_ids)} - going back to regrade",
271
+ )
272
+ self.last_graded_net_ids = None
273
+ return True # Signal to go back to previous student
274
+ continue
235
275
 
236
276
  # Record score - save submit_time and ensure the student is in the deductions file
237
277
  # (even if they have no deductions, to indicate they were graded)
238
278
  if pending_submit_time is not None:
239
279
  self.student_deductions.set_submit_time(tuple(net_ids), pending_submit_time)
240
280
  self.student_deductions.ensure_student_in_file(tuple(net_ids))
241
- break
281
+ # Track this student as last graded for undo functionality
282
+ self.last_graded_net_ids = tuple(net_ids)
283
+ return False # Normal completion
284
+
285
+ # If we got here via break (CallbackFailed, build_only, dry_run, etc.)
286
+ return False
242
287
 
243
288
  def num_grades_needed_deductions(self, net_ids):
244
289
  """Return the number of group members who need a grade.
@@ -0,0 +1,324 @@
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
+
17
+
18
+ def get_score(
19
+ names,
20
+ max_points,
21
+ *,
22
+ allow_rebuild=True,
23
+ allow_rerun=True,
24
+ student_deductions,
25
+ net_ids,
26
+ last_graded_net_ids=None,
27
+ names_by_netid=None,
28
+ ):
29
+ """Prompts the user for a score for the grade column.
30
+
31
+ Args:
32
+ names: Student name(s) to display
33
+ max_points: Maximum points for this item
34
+ allow_rebuild: Whether rebuild option is available
35
+ allow_rerun: Whether rerun option is available
36
+ student_deductions: StudentDeductions object
37
+ net_ids: Tuple of net_ids for the current student
38
+ last_graded_net_ids: Tuple of net_ids for the last graded student (for undo)
39
+ names_by_netid: Dict mapping net_id -> (first_name, last_name) for search
40
+
41
+ Returns:
42
+ Either a numeric score (float) or a ScoreResult enum value
43
+ """
44
+ fpad2 = " " * 4
45
+ pad = 10
46
+
47
+ while True:
48
+ # Compute current score
49
+ deductions_total = student_deductions.total_deductions(tuple(net_ids))
50
+ computed_score = max(0, max_points - deductions_total)
51
+
52
+ print("")
53
+ print("-" * 60)
54
+
55
+ # Show current deductions for this student
56
+ current_deductions = student_deductions.get_student_deductions(tuple(net_ids))
57
+ print(
58
+ fpad2
59
+ + 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
+ # Accept score at the bottom of right column
96
+ right_items.append(("[Enter]", "Accept score"))
97
+
98
+ # Add manage grades and undo to left column
99
+ left_items.append(("[g]", "Manage grades"))
100
+ allowed_cmds["g"] = "manage"
101
+
102
+ # Add undo option last if there's a last graded student
103
+ if last_graded_net_ids is not None:
104
+ left_items.append(("[u]", f"Undo last ({last_graded_net_ids[0]})"))
105
+ allowed_cmds["u"] = ScoreResult.UNDO_LAST
106
+
107
+ # Format menu items in two columns
108
+ col_width = 38 # Each column width (2 columns * 38 = 76 < 80)
109
+ input_txt = (
110
+ TermColors.BLUE + "Enter a grade for " + names + ":" + TermColors.END + "\n"
111
+ )
112
+
113
+ # Combine left and right items row by row
114
+ max_rows = max(len(left_items), len(right_items))
115
+ for i in range(max_rows):
116
+ # Left column
117
+ if i < len(left_items):
118
+ key1, desc1 = left_items[i]
119
+ col1 = (
120
+ fpad2 + TermColors.BLUE + key1.ljust(pad) + TermColors.END + desc1
121
+ )
122
+ # Pad to column width (accounting for ANSI codes)
123
+ col1_visible_len = len(fpad2) + len(key1.ljust(pad)) + len(desc1)
124
+ col1_padded = col1 + " " * (col_width - col1_visible_len)
125
+ else:
126
+ col1_padded = " " * col_width
127
+
128
+ # Right column
129
+ if i < len(right_items):
130
+ key2, desc2 = right_items[i]
131
+ col2 = TermColors.BLUE + key2.ljust(pad) + TermColors.END + desc2
132
+ input_txt += col1_padded + col2 + "\n"
133
+ else:
134
+ input_txt += col1_padded.rstrip() + "\n"
135
+
136
+ # Show available deduction types to apply
137
+ input_txt += fpad2 + "Apply deduction(s):\n"
138
+ for (
139
+ deduction_id,
140
+ deduction_type,
141
+ ) in student_deductions.deduction_types.items():
142
+ input_txt += (
143
+ fpad2
144
+ + TermColors.BLUE
145
+ + f" [{deduction_id}]".ljust(pad)
146
+ + TermColors.END
147
+ + f"-{deduction_type.points}: {deduction_type.message}\n"
148
+ )
149
+
150
+ input_txt += TermColors.BLUE + ">>> " + TermColors.END
151
+
152
+ ################### Get and handle user input #######################
153
+ txt = input(input_txt)
154
+
155
+ # Check for Enter key to accept computed score
156
+ if txt == "":
157
+ print(f"Saving score: {computed_score}")
158
+ return computed_score
159
+
160
+ # Check for commands
161
+ if txt in allowed_cmds:
162
+ result = allowed_cmds[txt]
163
+
164
+ # Handle special cases that need to loop back
165
+ if result == "create":
166
+ deduction_id = student_deductions.create_deduction_type_interactive()
167
+ if deduction_id >= 0:
168
+ # Auto-apply the new deduction to this student
169
+ student_deductions.apply_deduction_to_student(
170
+ tuple(net_ids), deduction_id
171
+ )
172
+ continue
173
+ if result == "delete":
174
+ student_deductions.delete_deduction_type_interactive()
175
+ continue
176
+ if result == "manage":
177
+ _manage_grades_interactive(student_deductions, names_by_netid)
178
+ continue
179
+ return result
180
+
181
+ # Check for clear command (must be exactly "0", not in a list)
182
+ if txt == "0":
183
+ student_deductions.clear_student_deductions(tuple(net_ids))
184
+ print("Cleared all deductions for this student.")
185
+ continue
186
+
187
+ # Check for deduction ID input
188
+ # Supports comma-separated list of deduction IDs (e.g., "1,2,3")
189
+ parts = [p.strip() for p in txt.split(",")]
190
+ valid_ids = []
191
+ all_valid = True
192
+ for part in parts:
193
+ try:
194
+ deduction_id = int(part)
195
+ # Don't allow 0 or other special commands in the list
196
+ if deduction_id <= 0:
197
+ all_valid = False
198
+ break
199
+ if deduction_id in student_deductions.deduction_types:
200
+ valid_ids.append(deduction_id)
201
+ else:
202
+ all_valid = False
203
+ break
204
+ except ValueError:
205
+ all_valid = False
206
+ break
207
+
208
+ if all_valid and valid_ids:
209
+ # Apply the deductions
210
+ for deduction_id in valid_ids:
211
+ student_deductions.apply_deduction_to_student(
212
+ tuple(net_ids), deduction_id
213
+ )
214
+ deduction_type = student_deductions.deduction_types[deduction_id]
215
+ print(
216
+ f"Applied deduction: {deduction_type.message} (-{deduction_type.points})"
217
+ )
218
+ continue
219
+
220
+ print_color(TermColors.YELLOW, "Invalid input. Try again.")
221
+
222
+
223
+ def _manage_grades_interactive(student_deductions, names_by_netid=None):
224
+ """Interactive menu to manage (view/delete) grades for any student.
225
+
226
+ Args:
227
+ student_deductions: StudentDeductions object to manage
228
+ names_by_netid: Dict mapping net_id -> (first_name, last_name) for search
229
+ """
230
+ while True:
231
+ graded_students = student_deductions.get_graded_students()
232
+
233
+ print("\n" + "=" * 60)
234
+ print_color(TermColors.BLUE, "Manage Grades")
235
+ print("=" * 60)
236
+
237
+ if not graded_students:
238
+ print("No students have been graded yet.")
239
+ input("Press Enter to continue...")
240
+ return
241
+
242
+ print(f"{len(graded_students)} student(s) graded.")
243
+ print("\nOptions:")
244
+ print(" [search] Enter search string to find student by name or net_id")
245
+ print(" [*] List all graded students")
246
+ print(" [Enter] Return to grading")
247
+
248
+ txt = input("\nSearch: ").strip()
249
+
250
+ if txt == "":
251
+ return
252
+
253
+ # Check for wildcard to list all
254
+ list_all = txt == "*"
255
+
256
+ # Search for matching students (case-insensitive)
257
+ search_lower = txt.lower()
258
+ matches = []
259
+
260
+ for net_ids in graded_students:
261
+ match_found = list_all # If listing all, match everything
262
+ display_parts = []
263
+
264
+ for net_id in net_ids:
265
+ # Check if search matches net_id (skip if listing all)
266
+ if not list_all and search_lower in net_id.lower():
267
+ match_found = True
268
+
269
+ # Check if search matches first/last name
270
+ if names_by_netid and net_id in names_by_netid:
271
+ first_name, last_name = names_by_netid[net_id]
272
+ if not list_all and (search_lower in first_name.lower() or search_lower in last_name.lower()):
273
+ match_found = True
274
+ display_parts.append(f"{first_name} {last_name} ({net_id})")
275
+ else:
276
+ display_parts.append(net_id)
277
+
278
+ if match_found:
279
+ matches.append((net_ids, ", ".join(display_parts)))
280
+
281
+ if not matches:
282
+ print_color(TermColors.YELLOW, f"No graded students match '{txt}'")
283
+ continue
284
+
285
+ # Sort matches by display name
286
+ matches.sort(key=lambda x: x[1].lower())
287
+
288
+ # Display matches and let user pick one
289
+ print(f"\nFound {len(matches)} match(es):")
290
+ for i, (net_ids, display) in enumerate(matches, 1):
291
+ deductions = student_deductions.get_student_deductions(net_ids)
292
+ deduction_count = len(deductions)
293
+ total_deducted = student_deductions.total_deductions(net_ids)
294
+ print(
295
+ f" [{i}] {display} "
296
+ f"({deduction_count} deduction(s), -{total_deducted} pts)"
297
+ )
298
+
299
+ print("\n [#] Enter number to delete that student's grade")
300
+ print(" [Enter] Cancel")
301
+
302
+ choice = input("\n>>> ").strip()
303
+
304
+ if choice == "":
305
+ continue
306
+
307
+ try:
308
+ idx = int(choice) - 1
309
+ if 0 <= idx < len(matches):
310
+ student_key, display = matches[idx]
311
+ # Confirm deletion
312
+ confirm = input(
313
+ f"Delete grade for {display}? This cannot be undone. [y/N]: "
314
+ ).strip().lower()
315
+
316
+ if confirm == "y":
317
+ student_deductions.clear_student_deductions(student_key)
318
+ print_color(TermColors.GREEN, f"Deleted grade for {display}")
319
+ else:
320
+ print("Cancelled.")
321
+ else:
322
+ print_color(TermColors.YELLOW, "Invalid selection.")
323
+ except ValueError:
324
+ print_color(TermColors.YELLOW, "Invalid input.")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ygrader
3
- Version: 2.2.0
3
+ Version: 2.4.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
@@ -1,231 +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
-
16
-
17
- def get_score(
18
- names,
19
- max_points,
20
- *,
21
- allow_rebuild=True,
22
- allow_rerun=True,
23
- student_deductions=None,
24
- net_ids=None,
25
- ):
26
- """Prompts the user for a score for the grade column.
27
-
28
- Args:
29
- names: Student name(s) to display
30
- max_points: Maximum points for this item (or None if no limit)
31
- allow_rebuild: Whether rebuild option is available
32
- allow_rerun: Whether rerun option is available
33
- student_deductions: StudentDeductions object (for deductions mode)
34
- net_ids: Tuple of net_ids for the current student (for deductions mode)
35
-
36
- Returns:
37
- Either a numeric score (float) or a ScoreResult enum value
38
- """
39
- fpad2 = " " * 4
40
- pad = 10
41
-
42
- # Check if we're in deductions mode
43
- deductions_mode = student_deductions is not None and net_ids is not None
44
-
45
- while True:
46
- # Compute score if in deductions mode
47
- computed_score = None
48
- if deductions_mode:
49
- deductions_total = student_deductions.total_deductions(tuple(net_ids))
50
- computed_score = max(0, max_points - deductions_total)
51
-
52
- print("")
53
- print("-" * 60)
54
-
55
- # Show current deductions for this student if in deductions mode (in white)
56
- if deductions_mode:
57
- current_deductions = student_deductions.get_student_deductions(
58
- tuple(net_ids)
59
- )
60
- print(
61
- fpad2
62
- + f"Current score: {TermColors.GREEN}{computed_score}{TermColors.END}"
63
- )
64
- print(fpad2 + "Current deductions:")
65
- if current_deductions:
66
- for d in current_deductions:
67
- print(fpad2 + f" -{d.points}: {d.message}")
68
- else:
69
- print(fpad2 + " (None)")
70
- print("")
71
-
72
- ################### Build input menu #######################
73
- # Build menu items for two-column display (left_items, right_items)
74
- left_items = []
75
- right_items = []
76
- allowed_cmds = {}
77
-
78
- # Show computed score if in deductions mode
79
- if deductions_mode:
80
- left_items.append(("[Enter]", "Accept score"))
81
- else:
82
- # Add score input (only for manual mode)
83
- key = ("0-" + str(max_points)) if max_points else "#"
84
- left_items.append((key, "Enter score"))
85
-
86
- left_items.append(("[s]", "Skip student"))
87
- allowed_cmds["s"] = ScoreResult.SKIP
88
-
89
- if allow_rebuild:
90
- left_items.append(("[b]", "Build & run"))
91
- allowed_cmds["b"] = ScoreResult.REBUILD
92
- if allow_rerun:
93
- desc = "Re-run" if not allow_rebuild else "Re-run (no build)"
94
- left_items.append(("[r]", desc))
95
- allowed_cmds["r"] = ScoreResult.RERUN
96
-
97
- # Add deduction options to right column (only in deductions mode)
98
- if deductions_mode:
99
- right_items.append(("[c]", "Create deduction"))
100
- allowed_cmds["c"] = "create"
101
-
102
- right_items.append(("[d]", "Delete deduction"))
103
- allowed_cmds["d"] = "delete"
104
-
105
- right_items.append(("[0]", "Clear deductions"))
106
-
107
- # Format menu items in two columns
108
- col_width = 38 # Each column width (2 columns * 38 = 76 < 80)
109
- input_txt = (
110
- TermColors.BLUE + "Enter a grade for " + names + ":" + TermColors.END + "\n"
111
- )
112
-
113
- # Combine left and right items row by row
114
- max_rows = max(len(left_items), len(right_items))
115
- for i in range(max_rows):
116
- # Left column
117
- if i < len(left_items):
118
- key1, desc1 = left_items[i]
119
- col1 = (
120
- fpad2 + TermColors.BLUE + key1.ljust(pad) + TermColors.END + desc1
121
- )
122
- # Pad to column width (accounting for ANSI codes)
123
- col1_visible_len = len(fpad2) + len(key1.ljust(pad)) + len(desc1)
124
- col1_padded = col1 + " " * (col_width - col1_visible_len)
125
- else:
126
- col1_padded = " " * col_width
127
-
128
- # Right column
129
- if i < len(right_items):
130
- key2, desc2 = right_items[i]
131
- col2 = TermColors.BLUE + key2.ljust(pad) + TermColors.END + desc2
132
- input_txt += col1_padded + col2 + "\n"
133
- else:
134
- input_txt += col1_padded.rstrip() + "\n"
135
-
136
- # Show available deduction types to apply (keep single column for these)
137
- if deductions_mode:
138
- input_txt += fpad2 + "Apply deduction(s):\n"
139
- for (
140
- deduction_id,
141
- deduction_type,
142
- ) in student_deductions.deduction_types.items():
143
- input_txt += (
144
- fpad2
145
- + TermColors.BLUE
146
- + f" [{deduction_id}]".ljust(pad)
147
- + TermColors.END
148
- + f"-{deduction_type.points}: {deduction_type.message}\n"
149
- )
150
-
151
- input_txt += TermColors.BLUE + ">>> " + TermColors.END
152
-
153
- ################### Get and handle user input #######################
154
- txt = input(input_txt)
155
-
156
- # Check for Enter key to accept computed score
157
- if txt == "" and deductions_mode:
158
- print(f"Saving score: {computed_score}")
159
- return computed_score
160
-
161
- # Check for commands
162
- if txt in allowed_cmds:
163
- result = allowed_cmds[txt]
164
-
165
- # Handle special cases that need to loop back
166
- if result == "create":
167
- deduction_id = student_deductions.create_deduction_type_interactive()
168
- if deduction_id >= 0:
169
- # Auto-apply the new deduction to this student
170
- student_deductions.apply_deduction_to_student(
171
- tuple(net_ids), deduction_id
172
- )
173
- continue
174
- if result == "delete":
175
- student_deductions.delete_deduction_type_interactive()
176
- continue
177
- return result
178
-
179
- # Check for deduction ID input (only in deductions mode)
180
- # 0 is reserved for clearing all deductions (must be standalone, not in a list)
181
- # Supports comma-separated list of deduction IDs (e.g., "1,2,3")
182
- if deductions_mode:
183
- # Check for clear command (must be exactly "0", not in a list)
184
- if txt == "0":
185
- student_deductions.clear_student_deductions(tuple(net_ids))
186
- print("Cleared all deductions for this student.")
187
- continue
188
-
189
- # Split by comma and try to parse each as a deduction ID
190
- parts = [p.strip() for p in txt.split(",")]
191
- valid_ids = []
192
- all_valid = True
193
- for part in parts:
194
- try:
195
- deduction_id = int(part)
196
- # Don't allow 0 or other special commands in the list
197
- if deduction_id <= 0:
198
- all_valid = False
199
- break
200
- if deduction_id in student_deductions.deduction_types:
201
- valid_ids.append(deduction_id)
202
- else:
203
- all_valid = False
204
- break
205
- except ValueError:
206
- all_valid = False
207
- break
208
-
209
- if all_valid and valid_ids:
210
- # Apply the deductions
211
- for deduction_id in valid_ids:
212
- student_deductions.apply_deduction_to_student(
213
- tuple(net_ids), deduction_id
214
- )
215
- deduction_type = student_deductions.deduction_types[deduction_id]
216
- print(
217
- f"Applied deduction: {deduction_type.message} (-{deduction_type.points})"
218
- )
219
- continue
220
-
221
- # Check for numeric input (only allowed in manual mode)
222
- if not deductions_mode:
223
- try:
224
- score = float(txt)
225
- if (max_points is None) or (0 <= score <= max_points):
226
- return score
227
- print_color(TermColors.YELLOW, "Invalid input. Try again.")
228
- except ValueError:
229
- print_color(TermColors.YELLOW, "Invalid input. Try again.")
230
- else:
231
- print_color(TermColors.YELLOW, "Invalid input. Try again.")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes