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.
- {ygrader-2.6.5/ygrader.egg-info → ygrader-2.6.7}/PKG-INFO +1 -1
- {ygrader-2.6.5 → ygrader-2.6.7}/setup.py +1 -1
- {ygrader-2.6.5 → ygrader-2.6.7}/ygrader/feedback.py +18 -1
- {ygrader-2.6.5 → ygrader-2.6.7}/ygrader/grader.py +32 -1
- {ygrader-2.6.5 → ygrader-2.6.7}/ygrader/grading_item.py +79 -58
- ygrader-2.6.7/ygrader/score_input.py +517 -0
- {ygrader-2.6.5 → ygrader-2.6.7/ygrader.egg-info}/PKG-INFO +1 -1
- ygrader-2.6.5/ygrader/score_input.py +0 -343
- {ygrader-2.6.5 → ygrader-2.6.7}/LICENSE +0 -0
- {ygrader-2.6.5 → ygrader-2.6.7}/setup.cfg +0 -0
- {ygrader-2.6.5 → ygrader-2.6.7}/test/test_interactive.py +0 -0
- {ygrader-2.6.5 → ygrader-2.6.7}/test/test_unittest.py +0 -0
- {ygrader-2.6.5 → ygrader-2.6.7}/ygrader/__init__.py +0 -0
- {ygrader-2.6.5 → ygrader-2.6.7}/ygrader/deductions.py +0 -0
- {ygrader-2.6.5 → ygrader-2.6.7}/ygrader/grades_csv.py +0 -0
- {ygrader-2.6.5 → ygrader-2.6.7}/ygrader/grading_item_config.py +0 -0
- {ygrader-2.6.5 → ygrader-2.6.7}/ygrader/remote.py +0 -0
- {ygrader-2.6.5 → ygrader-2.6.7}/ygrader/send_ctrl_backtick.ahk +0 -0
- {ygrader-2.6.5 → ygrader-2.6.7}/ygrader/student_repos.py +0 -0
- {ygrader-2.6.5 → ygrader-2.6.7}/ygrader/upstream_merger.py +0 -0
- {ygrader-2.6.5 → ygrader-2.6.7}/ygrader/utils.py +0 -0
- {ygrader-2.6.5 → ygrader-2.6.7}/ygrader.egg-info/SOURCES.txt +0 -0
- {ygrader-2.6.5 → ygrader-2.6.7}/ygrader.egg-info/dependency_links.txt +0 -0
- {ygrader-2.6.5 → ygrader-2.6.7}/ygrader.egg-info/requires.txt +0 -0
- {ygrader-2.6.5 → ygrader-2.6.7}/ygrader.egg-info/top_level.txt +0 -0
|
@@ -4,7 +4,7 @@ setup(
|
|
|
4
4
|
name="ygrader",
|
|
5
5
|
packages=["ygrader"],
|
|
6
6
|
package_data={"ygrader": ["*.ahk"]},
|
|
7
|
-
version="2.6.
|
|
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.
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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 ==
|
|
280
|
+
if score == MenuCommand.SKIP:
|
|
267
281
|
return False
|
|
268
|
-
if score ==
|
|
282
|
+
if score == MenuCommand.BUILD:
|
|
269
283
|
continue
|
|
270
|
-
if score ==
|
|
284
|
+
if score == MenuCommand.RERUN:
|
|
271
285
|
# run again, but don't build
|
|
272
286
|
build = False
|
|
273
287
|
continue
|
|
274
|
-
if score ==
|
|
288
|
+
if score == MenuCommand.EXIT:
|
|
275
289
|
print_color(TermColors.BLUE, "Exiting grader")
|
|
276
290
|
sys.exit(0)
|
|
277
|
-
if score ==
|
|
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
|
-
|
|
281
|
-
|
|
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"
|
|
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,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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|