ygrader 1.1.28__tar.gz → 1.2.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-1.1.28/ygrader.egg-info → ygrader-1.2.0}/PKG-INFO +11 -6
- {ygrader-1.1.28 → ygrader-1.2.0}/setup.py +3 -2
- {ygrader-1.1.28 → ygrader-1.2.0}/test/test_interactive.py +5 -4
- {ygrader-1.1.28 → ygrader-1.2.0}/test/test_unittest.py +19 -13
- ygrader-1.2.0/ygrader/__init__.py +6 -0
- ygrader-1.2.0/ygrader/deductions.py +401 -0
- {ygrader-1.1.28 → ygrader-1.2.0}/ygrader/grader.py +178 -112
- ygrader-1.2.0/ygrader/grading_item.py +334 -0
- ygrader-1.2.0/ygrader/score_input.py +244 -0
- ygrader-1.2.0/ygrader/send_ctrl_backtick.ahk +1 -0
- {ygrader-1.1.28 → ygrader-1.2.0}/ygrader/student_repos.py +16 -2
- ygrader-1.2.0/ygrader/sub_items.py +129 -0
- {ygrader-1.1.28 → ygrader-1.2.0}/ygrader/utils.py +81 -2
- {ygrader-1.1.28 → ygrader-1.2.0/ygrader.egg-info}/PKG-INFO +11 -6
- {ygrader-1.1.28 → ygrader-1.2.0}/ygrader.egg-info/SOURCES.txt +4 -0
- {ygrader-1.1.28 → ygrader-1.2.0}/ygrader.egg-info/requires.txt +1 -0
- ygrader-1.1.28/ygrader/__init__.py +0 -5
- ygrader-1.1.28/ygrader/grading_item.py +0 -357
- {ygrader-1.1.28 → ygrader-1.2.0}/LICENSE +0 -0
- {ygrader-1.1.28 → ygrader-1.2.0}/setup.cfg +0 -0
- {ygrader-1.1.28 → ygrader-1.2.0}/ygrader/grades_csv.py +0 -0
- {ygrader-1.1.28 → ygrader-1.2.0}/ygrader/upstream_merger.py +0 -0
- {ygrader-1.1.28 → ygrader-1.2.0}/ygrader.egg-info/dependency_links.txt +0 -0
- {ygrader-1.1.28 → ygrader-1.2.0}/ygrader.egg-info/top_level.txt +0 -0
|
@@ -1,13 +1,18 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: ygrader
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.2.0
|
|
4
4
|
Summary: Grading scripts used in BYU's Electrical and Computer Engineering Department
|
|
5
5
|
Home-page: https://github.com/byu-cpe/ygrader
|
|
6
6
|
Author: Jeff Goeders
|
|
7
7
|
Author-email: jeff.goeders@gmail.com
|
|
8
8
|
License: MIT
|
|
9
|
-
Platform: UNKNOWN
|
|
10
9
|
License-File: LICENSE
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
Requires-Dist: pandas>=1.0.0
|
|
11
|
+
Requires-Dist: pyyaml
|
|
12
|
+
Dynamic: author
|
|
13
|
+
Dynamic: author-email
|
|
14
|
+
Dynamic: home-page
|
|
15
|
+
Dynamic: license
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
Dynamic: requires-dist
|
|
18
|
+
Dynamic: summary
|
|
@@ -3,11 +3,12 @@ from setuptools import setup
|
|
|
3
3
|
setup(
|
|
4
4
|
name="ygrader",
|
|
5
5
|
packages=["ygrader"],
|
|
6
|
-
|
|
6
|
+
package_data={"ygrader": ["*.ahk"]},
|
|
7
|
+
version="1.2.0",
|
|
7
8
|
description="Grading scripts used in BYU's Electrical and Computer Engineering Department",
|
|
8
9
|
author="Jeff Goeders",
|
|
9
10
|
author_email="jeff.goeders@gmail.com",
|
|
10
11
|
license="MIT",
|
|
11
12
|
url="https://github.com/byu-cpe/ygrader",
|
|
12
|
-
install_requires=["pandas>=1.0.0"],
|
|
13
|
+
install_requires=["pandas>=1.0.0", "pyyaml"],
|
|
13
14
|
)
|
|
@@ -32,14 +32,15 @@ def test_me():
|
|
|
32
32
|
work_path=TEST_PATH / "temp",
|
|
33
33
|
)
|
|
34
34
|
grader.add_item_to_grade(
|
|
35
|
-
"lab1",
|
|
35
|
+
"lab1",
|
|
36
|
+
run_on_milestone,
|
|
37
|
+
10,
|
|
38
|
+
help_msg="This is a long string\nWith lots of advice\nFor TAs",
|
|
36
39
|
)
|
|
37
40
|
grader.add_item_to_grade(
|
|
38
41
|
"lab1m2",
|
|
39
42
|
run_on_milestone,
|
|
40
|
-
help_msg=
|
|
41
|
-
"msg2",
|
|
42
|
-
],
|
|
43
|
+
help_msg="msg2",
|
|
43
44
|
)
|
|
44
45
|
grader.set_submission_system_learning_suite(TEST_RESOURCES_PATH / "submissions.zip")
|
|
45
46
|
grader.set_other_options(prep_fcn=run_on_lab)
|
|
@@ -29,10 +29,10 @@ class TestGithub(unittest.TestCase):
|
|
|
29
29
|
grader = Grader(
|
|
30
30
|
lab_name="github_test",
|
|
31
31
|
grades_csv_path=TEST_RESOURCES_PATH / "grades1.csv",
|
|
32
|
-
work_path=TEST_PATH / "
|
|
32
|
+
work_path=TEST_PATH / "temp_github",
|
|
33
33
|
)
|
|
34
|
-
grader.add_item_to_grade("lab1", self.runner, 10)
|
|
35
|
-
grader.add_item_to_grade("lab1m2", self.runner, 20)
|
|
34
|
+
grader.add_item_to_grade("lab1", self.runner, max_points=10)
|
|
35
|
+
grader.add_item_to_grade("lab1m2", self.runner, max_points=20)
|
|
36
36
|
grader.set_submission_system_github(
|
|
37
37
|
"main", TEST_RESOURCES_PATH / "github.csv", use_https=True
|
|
38
38
|
)
|
|
@@ -53,14 +53,16 @@ class TestLearningSuite(unittest.TestCase):
|
|
|
53
53
|
grader = Grader(
|
|
54
54
|
lab_name="learningsuite_test",
|
|
55
55
|
grades_csv_path=grades_path,
|
|
56
|
-
work_path=TEST_PATH / "
|
|
56
|
+
work_path=TEST_PATH / "temp_learningsuite",
|
|
57
57
|
)
|
|
58
58
|
grader.add_item_to_grade(
|
|
59
|
-
|
|
59
|
+
csv_col_name="lab1",
|
|
60
60
|
grading_fcn=self.runner,
|
|
61
|
-
max_points=
|
|
61
|
+
max_points=10,
|
|
62
|
+
)
|
|
63
|
+
grader.set_submission_system_learning_suite(
|
|
64
|
+
TEST_RESOURCES_PATH / "submissions.zip"
|
|
62
65
|
)
|
|
63
|
-
grader.set_submission_system_learning_suite(TEST_RESOURCES_PATH / "submissions.zip")
|
|
64
66
|
grader.run()
|
|
65
67
|
|
|
66
68
|
self.assertTrue(filecmp.cmp(grades_path, grades_path_golden))
|
|
@@ -79,16 +81,17 @@ class TestLearningSuite(unittest.TestCase):
|
|
|
79
81
|
grader = Grader(
|
|
80
82
|
"groups_test",
|
|
81
83
|
TEST_RESOURCES_PATH / "grades3.csv",
|
|
82
|
-
work_path=TEST_PATH / "
|
|
84
|
+
work_path=TEST_PATH / "temp_groups",
|
|
83
85
|
)
|
|
84
86
|
grader.add_item_to_grade(
|
|
85
|
-
|
|
87
|
+
csv_col_name="l1",
|
|
86
88
|
grading_fcn=self.group_grader_1,
|
|
87
89
|
)
|
|
88
|
-
grader.add_item_to_grade(
|
|
89
|
-
|
|
90
|
+
grader.add_item_to_grade("l2", self.group_grader_2, help_msg="rubric message 1")
|
|
91
|
+
grader.add_item_to_grade("l3", self.group_grader_3, help_msg="rubric message 2")
|
|
92
|
+
grader.set_submission_system_learning_suite(
|
|
93
|
+
TEST_RESOURCES_PATH / "submissions2.zip"
|
|
90
94
|
)
|
|
91
|
-
grader.set_submission_system_learning_suite(TEST_RESOURCES_PATH / "submissions2.zip")
|
|
92
95
|
grader.set_learning_suite_groups(TEST_RESOURCES_PATH / "groups3.csv")
|
|
93
96
|
grader.run()
|
|
94
97
|
|
|
@@ -96,4 +99,7 @@ class TestLearningSuite(unittest.TestCase):
|
|
|
96
99
|
return 1.5
|
|
97
100
|
|
|
98
101
|
def group_grader_2(self, **kw):
|
|
99
|
-
return
|
|
102
|
+
return 2
|
|
103
|
+
|
|
104
|
+
def group_grader_3(self, **kw):
|
|
105
|
+
return 3.0
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Deduction system for student assignments.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import pathlib
|
|
6
|
+
from typing import List, Optional
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
from .utils import TermColors, print_color
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FlowList(list):
|
|
14
|
+
"""A list subclass that will be serialized in YAML flow style (inline)."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def flow_list_representer(dumper, data):
|
|
18
|
+
"""Custom YAML representer for FlowList to use flow style."""
|
|
19
|
+
return dumper.represent_sequence("tag:yaml.org,2002:seq", data, flow_style=True)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
yaml.add_representer(FlowList, flow_list_representer)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class DeductionType:
|
|
26
|
+
"""A reusable deduction type that can be applied across multiple students."""
|
|
27
|
+
|
|
28
|
+
message: str
|
|
29
|
+
points: float = 0.0
|
|
30
|
+
|
|
31
|
+
def __init__(self, message: str, points: float = 0.0):
|
|
32
|
+
self.message = message
|
|
33
|
+
self.points = points
|
|
34
|
+
|
|
35
|
+
def __str__(self) -> str:
|
|
36
|
+
"""String representation of the deduction type."""
|
|
37
|
+
if self.points != 0:
|
|
38
|
+
return f"{self.message} ({self.points:+.1f} points)"
|
|
39
|
+
return self.message
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class StudentDeductions:
|
|
43
|
+
"""Collection of all deductions for students."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, yaml_path: Optional[pathlib.Path] = None):
|
|
46
|
+
self.deductions_by_students = {}
|
|
47
|
+
self.days_late_by_students = {}
|
|
48
|
+
self.deduction_types = {}
|
|
49
|
+
self.yaml_path = yaml_path
|
|
50
|
+
|
|
51
|
+
# Load from YAML file if it exists
|
|
52
|
+
if yaml_path is not None and yaml_path.is_file():
|
|
53
|
+
self._load_from_yaml()
|
|
54
|
+
|
|
55
|
+
def _save(self):
|
|
56
|
+
"""Save the current state to YAML file if a path is set."""
|
|
57
|
+
if self.yaml_path is not None:
|
|
58
|
+
self._write_yaml()
|
|
59
|
+
|
|
60
|
+
def _load_from_yaml(self):
|
|
61
|
+
"""Load deduction types and student deductions from the YAML file.
|
|
62
|
+
|
|
63
|
+
Expected YAML structure:
|
|
64
|
+
deduction_types:
|
|
65
|
+
- id: 0
|
|
66
|
+
desc: Deduction description
|
|
67
|
+
points: 5
|
|
68
|
+
|
|
69
|
+
student_deductions:
|
|
70
|
+
- net_ids: ["idA", "idB"]
|
|
71
|
+
deductions: [0, 3]
|
|
72
|
+
"""
|
|
73
|
+
assert self.yaml_path.exists(), f"YAML file {self.yaml_path} does not exist."
|
|
74
|
+
|
|
75
|
+
with open(self.yaml_path, "r", encoding="utf-8") as f:
|
|
76
|
+
data = yaml.safe_load(f)
|
|
77
|
+
|
|
78
|
+
# Load deduction types if present
|
|
79
|
+
if data and "deduction_types" in data:
|
|
80
|
+
for deduction in data["deduction_types"]:
|
|
81
|
+
deduction_id = deduction["id"]
|
|
82
|
+
desc = deduction["desc"]
|
|
83
|
+
points = deduction["points"]
|
|
84
|
+
|
|
85
|
+
# Create a DeductionType for this deduction
|
|
86
|
+
deduction_type = DeductionType(message=desc, points=points)
|
|
87
|
+
self.deduction_types[deduction_id] = deduction_type
|
|
88
|
+
|
|
89
|
+
# Load student deductions if present
|
|
90
|
+
if data and "student_deductions" in data:
|
|
91
|
+
for entry in data["student_deductions"]:
|
|
92
|
+
net_ids = entry["net_ids"]
|
|
93
|
+
deduction_ids = entry["deductions"]
|
|
94
|
+
|
|
95
|
+
# Use tuple of net_ids as the key
|
|
96
|
+
student_key = tuple(net_ids)
|
|
97
|
+
deduction_items = []
|
|
98
|
+
|
|
99
|
+
for deduction_id in deduction_ids:
|
|
100
|
+
if deduction_id in self.deduction_types:
|
|
101
|
+
deduction_items.append(self.deduction_types[deduction_id])
|
|
102
|
+
|
|
103
|
+
self.deductions_by_students[student_key] = deduction_items
|
|
104
|
+
|
|
105
|
+
# Load days_late if present
|
|
106
|
+
if "days_late" in entry:
|
|
107
|
+
self.days_late_by_students[student_key] = entry["days_late"]
|
|
108
|
+
|
|
109
|
+
def _write_yaml(self):
|
|
110
|
+
"""Write deduction types and student deductions to the YAML file.
|
|
111
|
+
|
|
112
|
+
Writes in the format:
|
|
113
|
+
deduction_types:
|
|
114
|
+
- id: 0
|
|
115
|
+
desc: Deduction description
|
|
116
|
+
points: 5
|
|
117
|
+
|
|
118
|
+
student_deductions:
|
|
119
|
+
- net_ids: ["idA", "idB"]
|
|
120
|
+
deductions: [0, 3]
|
|
121
|
+
"""
|
|
122
|
+
data = {}
|
|
123
|
+
|
|
124
|
+
# Write deduction types
|
|
125
|
+
if self.deduction_types:
|
|
126
|
+
deduction_list = []
|
|
127
|
+
for deduction_id, deduction_type in self.deduction_types.items():
|
|
128
|
+
deduction_list.append(
|
|
129
|
+
{
|
|
130
|
+
"id": deduction_id,
|
|
131
|
+
"desc": deduction_type.message,
|
|
132
|
+
"points": deduction_type.points,
|
|
133
|
+
}
|
|
134
|
+
)
|
|
135
|
+
data["deduction_types"] = deduction_list
|
|
136
|
+
|
|
137
|
+
# Write student deductions (include students with deductions OR days_late)
|
|
138
|
+
all_student_keys = set(self.deductions_by_students.keys()) | set(
|
|
139
|
+
self.days_late_by_students.keys()
|
|
140
|
+
)
|
|
141
|
+
if all_student_keys:
|
|
142
|
+
student_deduction_list = []
|
|
143
|
+
for student_key in all_student_keys:
|
|
144
|
+
deduction_items = self.deductions_by_students.get(student_key, [])
|
|
145
|
+
# Find the deduction IDs for these deduction items
|
|
146
|
+
deduction_ids = []
|
|
147
|
+
for deduction_item in deduction_items:
|
|
148
|
+
# Find the ID of this deduction item in deduction_types
|
|
149
|
+
for deduction_id, dt_item in self.deduction_types.items():
|
|
150
|
+
if dt_item is deduction_item:
|
|
151
|
+
deduction_ids.append(deduction_id)
|
|
152
|
+
break
|
|
153
|
+
|
|
154
|
+
student_deduction_list.append(
|
|
155
|
+
{
|
|
156
|
+
"net_ids": FlowList(student_key),
|
|
157
|
+
"deductions": FlowList(deduction_ids),
|
|
158
|
+
**(
|
|
159
|
+
{"days_late": self.days_late_by_students[student_key]}
|
|
160
|
+
if student_key in self.days_late_by_students
|
|
161
|
+
else {}
|
|
162
|
+
),
|
|
163
|
+
}
|
|
164
|
+
)
|
|
165
|
+
data["student_deductions"] = student_deduction_list
|
|
166
|
+
|
|
167
|
+
# Write to file
|
|
168
|
+
with open(self.yaml_path, "w", encoding="utf-8") as f:
|
|
169
|
+
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
|
170
|
+
|
|
171
|
+
def add_deduction_type(self, message: str, points: float = 0.0) -> int:
|
|
172
|
+
"""Add a new deduction type.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
message: The deduction message/description
|
|
176
|
+
points: Points to deduct (can be negative for bonus points)
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
The ID assigned to this deduction type
|
|
180
|
+
"""
|
|
181
|
+
# Find the next available ID (start at 1, reserve 0 for clear command)
|
|
182
|
+
if self.deduction_types:
|
|
183
|
+
next_id = max(self.deduction_types.keys()) + 1
|
|
184
|
+
else:
|
|
185
|
+
next_id = 1
|
|
186
|
+
|
|
187
|
+
# Create and store the deduction type
|
|
188
|
+
deduction_type = DeductionType(message=message, points=points)
|
|
189
|
+
self.deduction_types[next_id] = deduction_type
|
|
190
|
+
|
|
191
|
+
self._save()
|
|
192
|
+
return next_id
|
|
193
|
+
|
|
194
|
+
def create_deduction_type_interactive(self) -> int:
|
|
195
|
+
"""Interactively prompt the user to create a new deduction type.
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
The ID of the created deduction type, or -1 if cancelled.
|
|
199
|
+
"""
|
|
200
|
+
print("\nCreate new deduction type (empty input to cancel):")
|
|
201
|
+
|
|
202
|
+
# Prompt for description
|
|
203
|
+
description = input(" Description: ").strip()
|
|
204
|
+
if not description:
|
|
205
|
+
print("Cancelled.")
|
|
206
|
+
return -1
|
|
207
|
+
|
|
208
|
+
# Prompt for points
|
|
209
|
+
while True:
|
|
210
|
+
points_str = input(" Points to deduct: ").strip()
|
|
211
|
+
if not points_str:
|
|
212
|
+
print("Cancelled.")
|
|
213
|
+
return -1
|
|
214
|
+
try:
|
|
215
|
+
points = float(points_str)
|
|
216
|
+
break
|
|
217
|
+
except ValueError:
|
|
218
|
+
print("Invalid number. Try again.")
|
|
219
|
+
|
|
220
|
+
# Add the new deduction type
|
|
221
|
+
deduction_id = self.add_deduction_type(description, points)
|
|
222
|
+
print(
|
|
223
|
+
f"Created deduction type [{deduction_id}]: {description} ({points} points)"
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
return deduction_id
|
|
227
|
+
|
|
228
|
+
def is_deduction_in_use(self, deduction_id: int) -> bool:
|
|
229
|
+
"""Check if a deduction type is currently applied to any student.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
deduction_id: The ID of the deduction type to check.
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
True if any student has this deduction, False otherwise.
|
|
236
|
+
"""
|
|
237
|
+
if deduction_id not in self.deduction_types:
|
|
238
|
+
return False
|
|
239
|
+
|
|
240
|
+
deduction_type = self.deduction_types[deduction_id]
|
|
241
|
+
for student_deductions in self.deductions_by_students.values():
|
|
242
|
+
if deduction_type in student_deductions:
|
|
243
|
+
return True
|
|
244
|
+
return False
|
|
245
|
+
|
|
246
|
+
def delete_deduction_type(self, deduction_id: int) -> bool:
|
|
247
|
+
"""Delete a deduction type if it's not in use by any student.
|
|
248
|
+
|
|
249
|
+
Args:
|
|
250
|
+
deduction_id: The ID of the deduction type to delete.
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
True if deleted successfully, False if in use or not found.
|
|
254
|
+
"""
|
|
255
|
+
if deduction_id not in self.deduction_types:
|
|
256
|
+
return False
|
|
257
|
+
|
|
258
|
+
if self.is_deduction_in_use(deduction_id):
|
|
259
|
+
return False
|
|
260
|
+
|
|
261
|
+
del self.deduction_types[deduction_id]
|
|
262
|
+
self._save()
|
|
263
|
+
return True
|
|
264
|
+
|
|
265
|
+
def delete_deduction_type_interactive(self) -> bool:
|
|
266
|
+
"""Interactively prompt the user to delete a deduction type.
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
True if a deduction was deleted, False otherwise.
|
|
270
|
+
"""
|
|
271
|
+
if not self.deduction_types:
|
|
272
|
+
print("No deduction types to delete.")
|
|
273
|
+
return False
|
|
274
|
+
|
|
275
|
+
print("\nDelete deduction type (empty input to cancel):")
|
|
276
|
+
print("Available deduction types:")
|
|
277
|
+
for deduction_id, deduction_type in self.deduction_types.items():
|
|
278
|
+
in_use = " (IN USE)" if self.is_deduction_in_use(deduction_id) else ""
|
|
279
|
+
print(
|
|
280
|
+
f" [{deduction_id}] -{deduction_type.points}: {deduction_type.message}{in_use}"
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
id_str = input(" Enter ID to delete: ").strip()
|
|
284
|
+
if not id_str:
|
|
285
|
+
print("Cancelled.")
|
|
286
|
+
return False
|
|
287
|
+
|
|
288
|
+
try:
|
|
289
|
+
deduction_id = int(id_str)
|
|
290
|
+
except ValueError:
|
|
291
|
+
print("Invalid ID.")
|
|
292
|
+
return False
|
|
293
|
+
|
|
294
|
+
if deduction_id not in self.deduction_types:
|
|
295
|
+
print("Deduction type not found.")
|
|
296
|
+
return False
|
|
297
|
+
|
|
298
|
+
if self.is_deduction_in_use(deduction_id):
|
|
299
|
+
print_color(
|
|
300
|
+
TermColors.YELLOW,
|
|
301
|
+
"Cannot delete - deduction is in use by one or more students.",
|
|
302
|
+
)
|
|
303
|
+
return False
|
|
304
|
+
|
|
305
|
+
deduction_type = self.deduction_types[deduction_id]
|
|
306
|
+
self.delete_deduction_type(deduction_id)
|
|
307
|
+
print(f"Deleted deduction type [{deduction_id}]: {deduction_type.message}")
|
|
308
|
+
return True
|
|
309
|
+
|
|
310
|
+
def get_student_deductions(self, net_ids: tuple) -> List[DeductionType]:
|
|
311
|
+
"""Get the list of deductions applied to a student.
|
|
312
|
+
|
|
313
|
+
Args:
|
|
314
|
+
net_ids: Tuple of net_ids to look up.
|
|
315
|
+
|
|
316
|
+
Returns:
|
|
317
|
+
List of DeductionType objects applied to this student.
|
|
318
|
+
"""
|
|
319
|
+
student_key = tuple(net_ids) if not isinstance(net_ids, tuple) else net_ids
|
|
320
|
+
return self.deductions_by_students.get(student_key, [])
|
|
321
|
+
|
|
322
|
+
def apply_deduction_to_student(self, net_ids: tuple, deduction_id: int) -> bool:
|
|
323
|
+
"""Apply a deduction type to a student.
|
|
324
|
+
|
|
325
|
+
Args:
|
|
326
|
+
net_ids: Tuple of net_ids for the student.
|
|
327
|
+
deduction_id: The ID of the deduction type to apply.
|
|
328
|
+
|
|
329
|
+
Returns:
|
|
330
|
+
True if successful, False if deduction_id not found.
|
|
331
|
+
"""
|
|
332
|
+
if deduction_id not in self.deduction_types:
|
|
333
|
+
return False
|
|
334
|
+
|
|
335
|
+
student_key = tuple(net_ids) if not isinstance(net_ids, tuple) else net_ids
|
|
336
|
+
if student_key not in self.deductions_by_students:
|
|
337
|
+
self.deductions_by_students[student_key] = []
|
|
338
|
+
|
|
339
|
+
deduction_type = self.deduction_types[deduction_id]
|
|
340
|
+
if deduction_type not in self.deductions_by_students[student_key]:
|
|
341
|
+
self.deductions_by_students[student_key].append(deduction_type)
|
|
342
|
+
self._save()
|
|
343
|
+
|
|
344
|
+
return True
|
|
345
|
+
|
|
346
|
+
def clear_student_deductions(self, net_ids: tuple):
|
|
347
|
+
"""Clear all deductions for a student.
|
|
348
|
+
|
|
349
|
+
Args:
|
|
350
|
+
net_ids: Tuple of net_ids for the student.
|
|
351
|
+
"""
|
|
352
|
+
student_key = tuple(net_ids) if not isinstance(net_ids, tuple) else net_ids
|
|
353
|
+
if student_key in self.deductions_by_students:
|
|
354
|
+
del self.deductions_by_students[student_key]
|
|
355
|
+
if student_key in self.days_late_by_students:
|
|
356
|
+
del self.days_late_by_students[student_key]
|
|
357
|
+
self._save()
|
|
358
|
+
|
|
359
|
+
def set_days_late(self, net_ids: tuple, days_late: int):
|
|
360
|
+
"""Set the number of days late for a student.
|
|
361
|
+
|
|
362
|
+
Args:
|
|
363
|
+
net_ids: Tuple of net_ids for the student.
|
|
364
|
+
days_late: Number of business days late (0 or None to remove).
|
|
365
|
+
"""
|
|
366
|
+
student_key = tuple(net_ids) if not isinstance(net_ids, tuple) else net_ids
|
|
367
|
+
if days_late and days_late > 0:
|
|
368
|
+
self.days_late_by_students[student_key] = days_late
|
|
369
|
+
elif student_key in self.days_late_by_students:
|
|
370
|
+
del self.days_late_by_students[student_key]
|
|
371
|
+
self._save()
|
|
372
|
+
|
|
373
|
+
def get_days_late(self, net_ids: tuple) -> Optional[int]:
|
|
374
|
+
"""Get the number of days late for a student.
|
|
375
|
+
|
|
376
|
+
Args:
|
|
377
|
+
net_ids: Tuple of net_ids for the student.
|
|
378
|
+
|
|
379
|
+
Returns:
|
|
380
|
+
Number of business days late, or None if not set/on time.
|
|
381
|
+
"""
|
|
382
|
+
student_key = tuple(net_ids) if not isinstance(net_ids, tuple) else net_ids
|
|
383
|
+
return self.days_late_by_students.get(student_key)
|
|
384
|
+
|
|
385
|
+
def total_deductions(self, net_ids: Optional[tuple] = None) -> float:
|
|
386
|
+
"""Calculate the total deductions for a student or all students.
|
|
387
|
+
|
|
388
|
+
Args:
|
|
389
|
+
net_ids: Tuple of net_ids to look up. If None, returns 0.
|
|
390
|
+
|
|
391
|
+
Returns:
|
|
392
|
+
The total points deducted for the specified student(s).
|
|
393
|
+
"""
|
|
394
|
+
if net_ids is None:
|
|
395
|
+
return 0.0
|
|
396
|
+
|
|
397
|
+
# Look up the student by their net_ids tuple
|
|
398
|
+
student_key = tuple(net_ids) if not isinstance(net_ids, tuple) else net_ids
|
|
399
|
+
deduction_items = self.deductions_by_students.get(student_key, [])
|
|
400
|
+
|
|
401
|
+
return sum(item.points for item in deduction_items)
|