ygrader 2.6.6__tar.gz → 2.6.10__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.6/ygrader.egg-info → ygrader-2.6.10}/PKG-INFO +1 -1
- {ygrader-2.6.6 → ygrader-2.6.10}/setup.py +1 -1
- {ygrader-2.6.6 → ygrader-2.6.10}/test/test_unittest.py +3 -0
- {ygrader-2.6.6 → ygrader-2.6.10}/ygrader/grader.py +36 -4
- {ygrader-2.6.6 → ygrader-2.6.10}/ygrader/grading_item.py +29 -24
- ygrader-2.6.10/ygrader/score_input.py +517 -0
- {ygrader-2.6.6 → ygrader-2.6.10/ygrader.egg-info}/PKG-INFO +1 -1
- ygrader-2.6.6/ygrader/score_input.py +0 -343
- {ygrader-2.6.6 → ygrader-2.6.10}/LICENSE +0 -0
- {ygrader-2.6.6 → ygrader-2.6.10}/setup.cfg +0 -0
- {ygrader-2.6.6 → ygrader-2.6.10}/test/test_interactive.py +0 -0
- {ygrader-2.6.6 → ygrader-2.6.10}/ygrader/__init__.py +0 -0
- {ygrader-2.6.6 → ygrader-2.6.10}/ygrader/deductions.py +0 -0
- {ygrader-2.6.6 → ygrader-2.6.10}/ygrader/feedback.py +0 -0
- {ygrader-2.6.6 → ygrader-2.6.10}/ygrader/grades_csv.py +0 -0
- {ygrader-2.6.6 → ygrader-2.6.10}/ygrader/grading_item_config.py +0 -0
- {ygrader-2.6.6 → ygrader-2.6.10}/ygrader/remote.py +0 -0
- {ygrader-2.6.6 → ygrader-2.6.10}/ygrader/send_ctrl_backtick.ahk +0 -0
- {ygrader-2.6.6 → ygrader-2.6.10}/ygrader/student_repos.py +0 -0
- {ygrader-2.6.6 → ygrader-2.6.10}/ygrader/upstream_merger.py +0 -0
- {ygrader-2.6.6 → ygrader-2.6.10}/ygrader/utils.py +0 -0
- {ygrader-2.6.6 → ygrader-2.6.10}/ygrader.egg-info/SOURCES.txt +0 -0
- {ygrader-2.6.6 → ygrader-2.6.10}/ygrader.egg-info/dependency_links.txt +0 -0
- {ygrader-2.6.6 → ygrader-2.6.10}/ygrader.egg-info/requires.txt +0 -0
- {ygrader-2.6.6 → ygrader-2.6.10}/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.10",
|
|
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",
|
|
@@ -50,6 +50,7 @@ class TestGithub(unittest.TestCase):
|
|
|
50
50
|
grader.set_submission_system_github(
|
|
51
51
|
"main", TEST_RESOURCES_PATH / "github.csv", use_https=True
|
|
52
52
|
)
|
|
53
|
+
grader.set_other_options(show_completion_menu=False)
|
|
53
54
|
grader.run()
|
|
54
55
|
|
|
55
56
|
self.assertTrue(filecmp.cmp(deductions1_yaml_path, deductions1_golden_path))
|
|
@@ -83,6 +84,7 @@ class TestLearningSuite(unittest.TestCase):
|
|
|
83
84
|
grader.set_submission_system_learning_suite(
|
|
84
85
|
TEST_RESOURCES_PATH / "submissions.zip"
|
|
85
86
|
)
|
|
87
|
+
grader.set_other_options(show_completion_menu=False)
|
|
86
88
|
grader.run()
|
|
87
89
|
|
|
88
90
|
self.assertTrue(filecmp.cmp(deductions_path, deductions_golden_path))
|
|
@@ -127,6 +129,7 @@ class TestLearningSuite(unittest.TestCase):
|
|
|
127
129
|
TEST_RESOURCES_PATH / "submissions2.zip"
|
|
128
130
|
)
|
|
129
131
|
grader.set_learning_suite_groups(TEST_RESOURCES_PATH / "groups3.csv")
|
|
132
|
+
grader.set_other_options(show_completion_menu=False)
|
|
130
133
|
grader.run()
|
|
131
134
|
|
|
132
135
|
self.assertTrue(filecmp.cmp(deductions1_path, deductions1_golden_path))
|
|
@@ -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 display_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.show_completion_menu = True # Show menu when grading completes
|
|
101
104
|
self.learning_suite_submissions_zip_path = None
|
|
102
105
|
self.github_csv_path = None
|
|
103
106
|
self.github_csv_col_name = None
|
|
@@ -342,6 +345,7 @@ class Grader:
|
|
|
342
345
|
dry_run_all=False,
|
|
343
346
|
workflow_hash=None,
|
|
344
347
|
parallel_workers=None,
|
|
348
|
+
show_completion_menu=True,
|
|
345
349
|
):
|
|
346
350
|
"""
|
|
347
351
|
This can be used to set other options for the grader.
|
|
@@ -387,6 +391,9 @@ class Grader:
|
|
|
387
391
|
If set to an integer, process students in parallel using that many workers.
|
|
388
392
|
Only works with build_only mode since interactive grading cannot be parallelized.
|
|
389
393
|
Default is None (sequential processing).
|
|
394
|
+
show_completion_menu: bool
|
|
395
|
+
Whether to show the completion menu when all students are graded.
|
|
396
|
+
Default is True.
|
|
390
397
|
"""
|
|
391
398
|
self.format_code = format_code
|
|
392
399
|
self.build_only = build_only
|
|
@@ -416,6 +423,8 @@ class Grader:
|
|
|
416
423
|
if parallel_workers is not None and not build_only:
|
|
417
424
|
error("parallel_workers is only supported when build_only=True")
|
|
418
425
|
|
|
426
|
+
self.show_completion_menu = show_completion_menu
|
|
427
|
+
|
|
419
428
|
def _validate_config(self):
|
|
420
429
|
"""Check that everything has been configured before running"""
|
|
421
430
|
# Check that callback function has been set up
|
|
@@ -495,10 +504,10 @@ class Grader:
|
|
|
495
504
|
self._run_grading_sequential(student_grades_df, grouped_df)
|
|
496
505
|
|
|
497
506
|
def _run_grading_sequential(self, student_grades_df, grouped_df):
|
|
498
|
-
# Sort by
|
|
499
|
-
# After groupby().agg(list), "
|
|
507
|
+
# Sort by first name for consistent grading order (matches display name sorting in [g] menu)
|
|
508
|
+
# After groupby().agg(list), "First Name" is a list, so we sort by the first element
|
|
500
509
|
sorted_df = grouped_df.sort_values(
|
|
501
|
-
by="
|
|
510
|
+
by="First Name", key=lambda x: x.apply(lambda names: names[0].lower())
|
|
502
511
|
)
|
|
503
512
|
|
|
504
513
|
# Convert to list for index-based iteration (needed for going back)
|
|
@@ -588,8 +597,18 @@ class Grader:
|
|
|
588
597
|
# Go back to previous student
|
|
589
598
|
idx = prev_idx
|
|
590
599
|
prev_idx = None # Clear so we can't go back twice in a row
|
|
600
|
+
self.last_graded_net_ids = None # Clear since we're undoing
|
|
591
601
|
continue
|
|
592
602
|
|
|
603
|
+
# Track this student as last graded (only if all items completed normally)
|
|
604
|
+
if (
|
|
605
|
+
not go_back
|
|
606
|
+
and not self.build_only
|
|
607
|
+
and not self.dry_run_first
|
|
608
|
+
and not self.dry_run_all
|
|
609
|
+
):
|
|
610
|
+
self.last_graded_net_ids = tuple(net_ids)
|
|
611
|
+
|
|
593
612
|
if self.dry_run_first:
|
|
594
613
|
print_color(
|
|
595
614
|
TermColors.YELLOW,
|
|
@@ -601,6 +620,17 @@ class Grader:
|
|
|
601
620
|
prev_idx = idx
|
|
602
621
|
idx += 1
|
|
603
622
|
|
|
623
|
+
# Show completion menu when all students are done (unless disabled or in special modes)
|
|
624
|
+
if (
|
|
625
|
+
self.show_completion_menu
|
|
626
|
+
and not self.build_only
|
|
627
|
+
and not self.dry_run_first
|
|
628
|
+
and not self.dry_run_all
|
|
629
|
+
):
|
|
630
|
+
# Get names_by_netid from first item for display purposes
|
|
631
|
+
names_by_netid = self.items[0].names_by_netid if self.items else None
|
|
632
|
+
display_completion_menu(self.items, names_by_netid)
|
|
633
|
+
|
|
604
634
|
def _process_single_student_build(self, row):
|
|
605
635
|
"""Process a single student for parallel build mode. Returns (net_ids, success, message, log_path)."""
|
|
606
636
|
first_names = grades_csv.get_first_names(row)
|
|
@@ -896,7 +926,9 @@ class Grader:
|
|
|
896
926
|
student_work_path.mkdir(parents=True, exist_ok=True)
|
|
897
927
|
|
|
898
928
|
# Clone student repo
|
|
899
|
-
https_url = student_repos.convert_github_url_format(
|
|
929
|
+
https_url = student_repos.convert_github_url_format(
|
|
930
|
+
row["github_url"], to_https=True
|
|
931
|
+
)
|
|
900
932
|
print("Student repo url: " + https_url, file=output)
|
|
901
933
|
if not student_repos.clone_repo(
|
|
902
934
|
row["github_url"], self.github_tag, student_work_path, output=output
|
|
@@ -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)
|
|
@@ -151,9 +150,7 @@ class GradeItem:
|
|
|
151
150
|
student_code_path / ".github" / "workflows" / "submission.yml"
|
|
152
151
|
)
|
|
153
152
|
try:
|
|
154
|
-
verify_workflow_hash(
|
|
155
|
-
workflow_file_path, self.grader.workflow_hash
|
|
156
|
-
)
|
|
153
|
+
verify_workflow_hash(workflow_file_path, self.grader.workflow_hash)
|
|
157
154
|
except WorkflowHashError as e:
|
|
158
155
|
workflow_errors.append(f"Workflow hash mismatch: {e}")
|
|
159
156
|
|
|
@@ -196,9 +193,7 @@ class GradeItem:
|
|
|
196
193
|
TermColors.RED,
|
|
197
194
|
"This student may have modified the GitHub workflow system.",
|
|
198
195
|
)
|
|
199
|
-
print_color(
|
|
200
|
-
TermColors.RED, "The submission date CANNOT be guaranteed."
|
|
201
|
-
)
|
|
196
|
+
print_color(TermColors.RED, "The submission date CANNOT be guaranteed.")
|
|
202
197
|
print_color(TermColors.RED, "")
|
|
203
198
|
print_color(
|
|
204
199
|
TermColors.RED,
|
|
@@ -209,9 +204,11 @@ class GradeItem:
|
|
|
209
204
|
|
|
210
205
|
# Ask for confirmation before grading
|
|
211
206
|
while True:
|
|
212
|
-
response =
|
|
213
|
-
"Do you want to grade this student anyway? [y/n]: "
|
|
214
|
-
|
|
207
|
+
response = (
|
|
208
|
+
input("Do you want to grade this student anyway? [y/n]: ")
|
|
209
|
+
.strip()
|
|
210
|
+
.lower()
|
|
211
|
+
)
|
|
215
212
|
if response in ("y", "yes"):
|
|
216
213
|
break
|
|
217
214
|
if response in ("n", "no"):
|
|
@@ -271,35 +268,45 @@ class GradeItem:
|
|
|
271
268
|
allow_rerun=self.grader.allow_rerun,
|
|
272
269
|
student_deductions=self.student_deductions,
|
|
273
270
|
net_ids=tuple(net_ids),
|
|
274
|
-
last_graded_net_ids=self.last_graded_net_ids,
|
|
271
|
+
last_graded_net_ids=self.grader.last_graded_net_ids,
|
|
275
272
|
names_by_netid=self.names_by_netid,
|
|
273
|
+
all_items=self.grader.items,
|
|
276
274
|
)
|
|
277
275
|
except KeyboardInterrupt:
|
|
278
276
|
print_color(TermColors.RED, "\nExiting")
|
|
279
277
|
sys.exit(0)
|
|
280
278
|
|
|
281
|
-
if score ==
|
|
279
|
+
if score == MenuCommand.SKIP:
|
|
282
280
|
return False
|
|
283
|
-
if score ==
|
|
281
|
+
if score == MenuCommand.BUILD:
|
|
284
282
|
continue
|
|
285
|
-
if score ==
|
|
283
|
+
if score == MenuCommand.RERUN:
|
|
286
284
|
# run again, but don't build
|
|
287
285
|
build = False
|
|
288
286
|
continue
|
|
289
|
-
if score ==
|
|
287
|
+
if score == MenuCommand.EXIT:
|
|
290
288
|
print_color(TermColors.BLUE, "Exiting grader")
|
|
291
289
|
sys.exit(0)
|
|
292
|
-
if score ==
|
|
290
|
+
if score == MenuCommand.UNDO:
|
|
293
291
|
# Undo the last graded student and signal to go back
|
|
294
|
-
if self.last_graded_net_ids is not None:
|
|
295
|
-
|
|
296
|
-
|
|
292
|
+
if self.grader.last_graded_net_ids is not None:
|
|
293
|
+
# Clear deductions for ALL items for the last graded student
|
|
294
|
+
for item in self.grader.items:
|
|
295
|
+
item.student_deductions.clear_student_deductions(
|
|
296
|
+
self.grader.last_graded_net_ids
|
|
297
|
+
)
|
|
298
|
+
# Also clear any partial grades for the CURRENT student
|
|
299
|
+
# so they start fresh when we come back to them
|
|
300
|
+
for item in self.grader.items:
|
|
301
|
+
item.student_deductions.clear_student_deductions(tuple(net_ids))
|
|
302
|
+
print_color(
|
|
303
|
+
TermColors.GREEN,
|
|
304
|
+
f"Undid grade for {', '.join(self.grader.last_graded_net_ids)} - going back to regrade",
|
|
297
305
|
)
|
|
298
306
|
print_color(
|
|
299
307
|
TermColors.GREEN,
|
|
300
|
-
f"
|
|
308
|
+
f"Also cleared partial grades for {', '.join(net_ids)}",
|
|
301
309
|
)
|
|
302
|
-
self.last_graded_net_ids = None
|
|
303
310
|
return True # Signal to go back to previous student
|
|
304
311
|
continue
|
|
305
312
|
|
|
@@ -310,8 +317,6 @@ class GradeItem:
|
|
|
310
317
|
tuple(net_ids), pending_submit_time
|
|
311
318
|
)
|
|
312
319
|
self.student_deductions.ensure_student_in_file(tuple(net_ids))
|
|
313
|
-
# Track this student as last graded for undo functionality
|
|
314
|
-
self.last_graded_net_ids = tuple(net_ids)
|
|
315
320
|
return False # Normal completion
|
|
316
321
|
|
|
317
322
|
# 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 display_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 deduction in current_deductions:
|
|
195
|
+
print(fpad2 + f" -{deduction.points}: {deduction.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, student_deductions_arg):
|
|
332
|
+
self.student_deductions = student_deductions_arg
|
|
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
|