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