ygrader 2.6.6__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.6/ygrader.egg-info → ygrader-2.6.7}/PKG-INFO +1 -1
- {ygrader-2.6.6 → ygrader-2.6.7}/setup.py +1 -1
- {ygrader-2.6.6 → ygrader-2.6.7}/ygrader/grader.py +31 -1
- {ygrader-2.6.6 → ygrader-2.6.7}/ygrader/grading_item.py +30 -24
- ygrader-2.6.7/ygrader/score_input.py +517 -0
- {ygrader-2.6.6 → ygrader-2.6.7/ygrader.egg-info}/PKG-INFO +1 -1
- ygrader-2.6.6/ygrader/score_input.py +0 -343
- {ygrader-2.6.6 → ygrader-2.6.7}/LICENSE +0 -0
- {ygrader-2.6.6 → ygrader-2.6.7}/setup.cfg +0 -0
- {ygrader-2.6.6 → ygrader-2.6.7}/test/test_interactive.py +0 -0
- {ygrader-2.6.6 → ygrader-2.6.7}/test/test_unittest.py +0 -0
- {ygrader-2.6.6 → ygrader-2.6.7}/ygrader/__init__.py +0 -0
- {ygrader-2.6.6 → ygrader-2.6.7}/ygrader/deductions.py +0 -0
- {ygrader-2.6.6 → ygrader-2.6.7}/ygrader/feedback.py +0 -0
- {ygrader-2.6.6 → ygrader-2.6.7}/ygrader/grades_csv.py +0 -0
- {ygrader-2.6.6 → ygrader-2.6.7}/ygrader/grading_item_config.py +0 -0
- {ygrader-2.6.6 → ygrader-2.6.7}/ygrader/remote.py +0 -0
- {ygrader-2.6.6 → ygrader-2.6.7}/ygrader/send_ctrl_backtick.ahk +0 -0
- {ygrader-2.6.6 → ygrader-2.6.7}/ygrader/student_repos.py +0 -0
- {ygrader-2.6.6 → ygrader-2.6.7}/ygrader/upstream_merger.py +0 -0
- {ygrader-2.6.6 → ygrader-2.6.7}/ygrader/utils.py +0 -0
- {ygrader-2.6.6 → ygrader-2.6.7}/ygrader.egg-info/SOURCES.txt +0 -0
- {ygrader-2.6.6 → ygrader-2.6.7}/ygrader.egg-info/dependency_links.txt +0 -0
- {ygrader-2.6.6 → ygrader-2.6.7}/ygrader.egg-info/requires.txt +0 -0
- {ygrader-2.6.6 → 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",
|
|
@@ -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,9 @@ class Grader:
|
|
|
896
924
|
student_work_path.mkdir(parents=True, exist_ok=True)
|
|
897
925
|
|
|
898
926
|
# Clone student repo
|
|
899
|
-
https_url = student_repos.convert_github_url_format(
|
|
927
|
+
https_url = student_repos.convert_github_url_format(
|
|
928
|
+
row["github_url"], to_https=True
|
|
929
|
+
)
|
|
900
930
|
print("Student repo url: " + https_url, file=output)
|
|
901
931
|
if not student_repos.clone_repo(
|
|
902
932
|
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"):
|
|
@@ -263,6 +260,7 @@ class GradeItem:
|
|
|
263
260
|
|
|
264
261
|
# callback_result is None - interactive mode
|
|
265
262
|
# Prompt with deductions mode - handles everything internally
|
|
263
|
+
self.grader.interactive_grading_occurred = True
|
|
266
264
|
try:
|
|
267
265
|
score = get_score(
|
|
268
266
|
concated_names,
|
|
@@ -271,35 +269,45 @@ class GradeItem:
|
|
|
271
269
|
allow_rerun=self.grader.allow_rerun,
|
|
272
270
|
student_deductions=self.student_deductions,
|
|
273
271
|
net_ids=tuple(net_ids),
|
|
274
|
-
last_graded_net_ids=self.last_graded_net_ids,
|
|
272
|
+
last_graded_net_ids=self.grader.last_graded_net_ids,
|
|
275
273
|
names_by_netid=self.names_by_netid,
|
|
274
|
+
all_items=self.grader.items,
|
|
276
275
|
)
|
|
277
276
|
except KeyboardInterrupt:
|
|
278
277
|
print_color(TermColors.RED, "\nExiting")
|
|
279
278
|
sys.exit(0)
|
|
280
279
|
|
|
281
|
-
if score ==
|
|
280
|
+
if score == MenuCommand.SKIP:
|
|
282
281
|
return False
|
|
283
|
-
if score ==
|
|
282
|
+
if score == MenuCommand.BUILD:
|
|
284
283
|
continue
|
|
285
|
-
if score ==
|
|
284
|
+
if score == MenuCommand.RERUN:
|
|
286
285
|
# run again, but don't build
|
|
287
286
|
build = False
|
|
288
287
|
continue
|
|
289
|
-
if score ==
|
|
288
|
+
if score == MenuCommand.EXIT:
|
|
290
289
|
print_color(TermColors.BLUE, "Exiting grader")
|
|
291
290
|
sys.exit(0)
|
|
292
|
-
if score ==
|
|
291
|
+
if score == MenuCommand.UNDO:
|
|
293
292
|
# Undo the last graded student and signal to go back
|
|
294
|
-
if self.last_graded_net_ids is not None:
|
|
295
|
-
|
|
296
|
-
|
|
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",
|
|
297
306
|
)
|
|
298
307
|
print_color(
|
|
299
308
|
TermColors.GREEN,
|
|
300
|
-
f"
|
|
309
|
+
f"Also cleared partial grades for {', '.join(net_ids)}",
|
|
301
310
|
)
|
|
302
|
-
self.last_graded_net_ids = None
|
|
303
311
|
return True # Signal to go back to previous student
|
|
304
312
|
continue
|
|
305
313
|
|
|
@@ -310,8 +318,6 @@ class GradeItem:
|
|
|
310
318
|
tuple(net_ids), pending_submit_time
|
|
311
319
|
)
|
|
312
320
|
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
321
|
return False # Normal completion
|
|
316
322
|
|
|
317
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
|
|
File without changes
|