ygrader 2.0.0__tar.gz → 2.1.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.
Files changed (24) hide show
  1. {ygrader-2.0.0/ygrader.egg-info → ygrader-2.1.0}/PKG-INFO +1 -1
  2. {ygrader-2.0.0 → ygrader-2.1.0}/setup.py +1 -1
  3. {ygrader-2.0.0 → ygrader-2.1.0}/ygrader/__init__.py +1 -1
  4. ygrader-2.1.0/ygrader/feedback.py +383 -0
  5. {ygrader-2.0.0 → ygrader-2.1.0}/ygrader/grading_item_config.py +1 -5
  6. {ygrader-2.0.0 → ygrader-2.1.0/ygrader.egg-info}/PKG-INFO +1 -1
  7. ygrader-2.0.0/ygrader/feedback.py +0 -218
  8. {ygrader-2.0.0 → ygrader-2.1.0}/LICENSE +0 -0
  9. {ygrader-2.0.0 → ygrader-2.1.0}/setup.cfg +0 -0
  10. {ygrader-2.0.0 → ygrader-2.1.0}/test/test_interactive.py +0 -0
  11. {ygrader-2.0.0 → ygrader-2.1.0}/test/test_unittest.py +0 -0
  12. {ygrader-2.0.0 → ygrader-2.1.0}/ygrader/deductions.py +0 -0
  13. {ygrader-2.0.0 → ygrader-2.1.0}/ygrader/grader.py +0 -0
  14. {ygrader-2.0.0 → ygrader-2.1.0}/ygrader/grades_csv.py +0 -0
  15. {ygrader-2.0.0 → ygrader-2.1.0}/ygrader/grading_item.py +0 -0
  16. {ygrader-2.0.0 → ygrader-2.1.0}/ygrader/score_input.py +0 -0
  17. {ygrader-2.0.0 → ygrader-2.1.0}/ygrader/send_ctrl_backtick.ahk +0 -0
  18. {ygrader-2.0.0 → ygrader-2.1.0}/ygrader/student_repos.py +0 -0
  19. {ygrader-2.0.0 → ygrader-2.1.0}/ygrader/upstream_merger.py +0 -0
  20. {ygrader-2.0.0 → ygrader-2.1.0}/ygrader/utils.py +0 -0
  21. {ygrader-2.0.0 → ygrader-2.1.0}/ygrader.egg-info/SOURCES.txt +0 -0
  22. {ygrader-2.0.0 → ygrader-2.1.0}/ygrader.egg-info/dependency_links.txt +0 -0
  23. {ygrader-2.0.0 → ygrader-2.1.0}/ygrader.egg-info/requires.txt +0 -0
  24. {ygrader-2.0.0 → ygrader-2.1.0}/ygrader.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ygrader
3
- Version: 2.0.0
3
+ Version: 2.1.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
@@ -4,7 +4,7 @@ setup(
4
4
  name="ygrader",
5
5
  packages=["ygrader"],
6
6
  package_data={"ygrader": ["*.ahk"]},
7
- version="2.0.0",
7
+ version="2.1.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",
@@ -7,4 +7,4 @@ from .grading_item_config import (
7
7
  LearningSuiteColumn,
8
8
  LearningSuiteColumnParseError,
9
9
  )
10
- from .feedback import generate_feedback_zip
10
+ from .feedback import assemble_grades
@@ -0,0 +1,383 @@
1
+ """Module for generating student feedback files and grades CSV."""
2
+
3
+ import pathlib
4
+ import zipfile
5
+ from typing import Callable, Dict, Optional, Tuple
6
+
7
+ import pandas
8
+
9
+ from .deductions import StudentDeductions
10
+ from .grading_item_config import LearningSuiteColumn
11
+ from .utils import warning
12
+
13
+
14
+ # Type alias for late penalty callback: (late_days, max_score, actual_score) -> new_score
15
+ LatePenaltyCallback = Callable[[int, float, float], float]
16
+
17
+
18
+ def _get_student_key_and_max_late_days(
19
+ net_id: str,
20
+ item_deductions: Dict[str, StudentDeductions],
21
+ ) -> tuple:
22
+ """Find the student key and maximum late days across all items.
23
+
24
+ Args:
25
+ net_id: The student's net ID.
26
+ item_deductions: Mapping from item name to StudentDeductions.
27
+
28
+ Returns:
29
+ Tuple of (student_key or None, max_late_days).
30
+ """
31
+ max_late_days = 0
32
+ found_student_key = None
33
+
34
+ for deductions_obj in item_deductions.values():
35
+ if not deductions_obj:
36
+ continue
37
+
38
+ # Find the student key
39
+ student_key = None
40
+ if (net_id,) in deductions_obj.deductions_by_students:
41
+ student_key = (net_id,)
42
+ elif (net_id,) in deductions_obj.days_late_by_students:
43
+ student_key = (net_id,)
44
+ else:
45
+ # Check for multi-student keys containing this net_id
46
+ for key in set(deductions_obj.deductions_by_students.keys()) | set(
47
+ deductions_obj.days_late_by_students.keys()
48
+ ):
49
+ if net_id in key:
50
+ student_key = key
51
+ break
52
+
53
+ if student_key:
54
+ found_student_key = student_key
55
+ days_late = deductions_obj.days_late_by_students.get(student_key, 0)
56
+ max_late_days = max(max_late_days, days_late)
57
+
58
+ return found_student_key, max_late_days
59
+
60
+
61
+ def _calculate_student_score(
62
+ net_id: str,
63
+ ls_column: LearningSuiteColumn,
64
+ item_deductions: Dict[str, StudentDeductions],
65
+ late_penalty_callback: Optional[LatePenaltyCallback] = None,
66
+ warn_on_missing_callback: bool = True,
67
+ ) -> Tuple[float, float, int]:
68
+ """Calculate a student's final score.
69
+
70
+ Args:
71
+ net_id: The student's net ID.
72
+ ls_column: The LearningSuiteColumn configuration.
73
+ item_deductions: Mapping from item name to StudentDeductions.
74
+ late_penalty_callback: Optional callback for late penalty.
75
+ warn_on_missing_callback: Whether to warn if late days found but no callback.
76
+
77
+ Returns:
78
+ Tuple of (final_score, total_possible, max_late_days).
79
+ """
80
+ total_possible = sum(item.points for item in ls_column.items)
81
+ total_deductions = 0.0
82
+
83
+ for item in ls_column.items:
84
+ deductions_obj = item_deductions.get(item.name)
85
+ if deductions_obj:
86
+ # Find the student's deductions
87
+ student_key = None
88
+ if (net_id,) in deductions_obj.deductions_by_students:
89
+ student_key = (net_id,)
90
+ else:
91
+ for key in deductions_obj.deductions_by_students.keys():
92
+ if net_id in key:
93
+ student_key = key
94
+ break
95
+
96
+ if student_key:
97
+ deductions = deductions_obj.deductions_by_students[student_key]
98
+ for deduction in deductions:
99
+ total_deductions += deduction.points
100
+
101
+ # Calculate score before late penalty
102
+ score = max(0, total_possible - total_deductions)
103
+
104
+ # Get max late days
105
+ _, max_late_days = _get_student_key_and_max_late_days(net_id, item_deductions)
106
+
107
+ # Apply late penalty if applicable
108
+ if max_late_days > 0:
109
+ if late_penalty_callback:
110
+ score = max(0, late_penalty_callback(max_late_days, total_possible, score))
111
+ elif warn_on_missing_callback:
112
+ warning(
113
+ f"Student {net_id} has {max_late_days} late day(s) but no late penalty callback provided"
114
+ )
115
+
116
+ return score, total_possible, max_late_days
117
+
118
+
119
+ def assemble_grades(
120
+ yaml_path: pathlib.Path,
121
+ class_list_csv_path: pathlib.Path,
122
+ subitem_feedback_paths: Dict[str, pathlib.Path],
123
+ *,
124
+ output_zip_path: Optional[pathlib.Path] = None,
125
+ output_csv_path: Optional[pathlib.Path] = None,
126
+ late_penalty_callback: Optional[LatePenaltyCallback] = None,
127
+ ) -> Tuple[Optional[pathlib.Path], Optional[pathlib.Path]]:
128
+ """Generate feedback zip and/or grades CSV from deductions.
129
+
130
+ Args:
131
+ yaml_path: Path to the YAML file that can be loaded by LearningSuiteColumn.
132
+ class_list_csv_path: Path to CSV file with class list (Net ID, First Name, Last Name).
133
+ subitem_feedback_paths: Mapping from subitem name to feedback YAML file path.
134
+ output_zip_path: Path for the output zip file. If None, no zip is generated.
135
+ output_csv_path: Path for the output CSV file. If None, no CSV is generated.
136
+ late_penalty_callback: Optional callback function that takes
137
+ (late_days, max_score, actual_score) and returns the adjusted score.
138
+
139
+ Returns:
140
+ Tuple of (feedback_zip_path or None, grades_csv_path or None).
141
+ """
142
+ yaml_path = pathlib.Path(yaml_path)
143
+ ls_column = LearningSuiteColumn(yaml_path)
144
+
145
+ # Get the lab name from the YAML file's parent directory
146
+ lab_name = yaml_path.parent.name
147
+
148
+ # Load all student deductions for each subitem
149
+ subitem_deductions: Dict[str, StudentDeductions] = {}
150
+ for subitem in ls_column.items:
151
+ if subitem.name in subitem_feedback_paths:
152
+ feedback_path = subitem_feedback_paths[subitem.name]
153
+ if feedback_path.exists():
154
+ subitem_deductions[subitem.name] = StudentDeductions(feedback_path)
155
+ else:
156
+ subitem_deductions[subitem.name] = StudentDeductions()
157
+ else:
158
+ subitem_deductions[subitem.name] = StudentDeductions()
159
+
160
+ # Load class list
161
+ students_df = pandas.read_csv(class_list_csv_path)
162
+
163
+ # Prepare for CSV output
164
+ grades_data = []
165
+
166
+ def process_students(zip_file: Optional[zipfile.ZipFile]) -> None:
167
+ """Process all students, adding to grades_data and optionally writing to zip."""
168
+ for _, student_row in students_df.iterrows():
169
+ first_name = str(student_row["First Name"]).strip()
170
+ last_name = str(student_row["Last Name"]).strip()
171
+ net_id = str(student_row["Net ID"]).strip()
172
+
173
+ # Calculate final score (only warn once, when generating CSV)
174
+ final_score, _, _ = _calculate_student_score(
175
+ net_id=net_id,
176
+ ls_column=ls_column,
177
+ item_deductions=subitem_deductions,
178
+ late_penalty_callback=late_penalty_callback,
179
+ warn_on_missing_callback=(output_csv_path is not None),
180
+ )
181
+
182
+ # Add to grades data
183
+ if output_csv_path:
184
+ grades_data.append(
185
+ {"Net ID": net_id, ls_column.csv_col_name: final_score}
186
+ )
187
+
188
+ # Generate feedback file
189
+ if zip_file:
190
+ feedback_content = _generate_student_feedback(
191
+ student_row=student_row,
192
+ ls_column=ls_column,
193
+ subitem_deductions=subitem_deductions,
194
+ late_penalty_callback=late_penalty_callback,
195
+ )
196
+
197
+ filename = (
198
+ first_name
199
+ + "_"
200
+ + last_name
201
+ + "_"
202
+ + net_id
203
+ + "_feedback-"
204
+ + lab_name
205
+ + ".txt"
206
+ )
207
+ zip_file.writestr(filename, feedback_content)
208
+
209
+ # Process students with or without zip file
210
+ if output_zip_path:
211
+ with zipfile.ZipFile(output_zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
212
+ process_students(zf)
213
+ else:
214
+ process_students(None)
215
+
216
+ # Write CSV (sorted by Net ID for easier git diffs)
217
+ if output_csv_path and grades_data:
218
+ grades_df = pandas.DataFrame(grades_data)
219
+ grades_df = grades_df.sort_values("Net ID")
220
+ grades_df.to_csv(output_csv_path, index=False)
221
+
222
+ return (output_zip_path, output_csv_path)
223
+
224
+
225
+ def _generate_student_feedback(
226
+ student_row: pandas.Series,
227
+ ls_column: LearningSuiteColumn,
228
+ subitem_deductions: Dict[str, StudentDeductions],
229
+ late_penalty_callback: Optional[LatePenaltyCallback] = None,
230
+ ) -> str:
231
+ """Generate the feedback text content for a single student.
232
+
233
+ Args:
234
+ student_row: A row from the student DataFrame.
235
+ ls_column: The LearningSuiteColumn object.
236
+ subitem_deductions: Mapping from subitem name to StudentDeductions.
237
+ late_penalty_callback: Optional callback for calculating late penalty.
238
+
239
+ Returns:
240
+ The formatted feedback text.
241
+ """
242
+ net_id = str(student_row["Net ID"]).strip()
243
+ first_name = str(student_row["First Name"]).strip()
244
+ last_name = str(student_row["Last Name"]).strip()
245
+
246
+ lines = []
247
+ lines.append(f"Feedback for {first_name} {last_name} ({net_id})")
248
+ lines.append("=" * 60)
249
+ lines.append("")
250
+
251
+ total_points_possible = 0
252
+ total_points_deducted = 0
253
+
254
+ # Column widths for formatting
255
+ # Total width is 60 characters to match separator lines
256
+ # Item name column + score column (e.g., "10.0 / 10.0")
257
+ item_col_width = 42
258
+ score_col_width = 17
259
+ # For deduction lines: " - " prefix (4 chars) + message + points
260
+ deduction_prefix = " - "
261
+ deduction_msg_width = 38
262
+ deduction_pts_width = 17
263
+
264
+ for item in ls_column.items:
265
+ subitem_points_possible = item.points
266
+ total_points_possible += subitem_points_possible
267
+
268
+ subitem_points_deducted = 0
269
+ item_deductions = []
270
+
271
+ # Get deductions for this student in this item
272
+ student_deductions_obj = subitem_deductions.get(item.name)
273
+ if student_deductions_obj:
274
+ # Find the student's deductions (try single net_id first, then tuple)
275
+ student_key = None
276
+ if (net_id,) in student_deductions_obj.deductions_by_students:
277
+ student_key = (net_id,)
278
+ else:
279
+ # Check for multi-student keys containing this net_id
280
+ for key in student_deductions_obj.deductions_by_students.keys():
281
+ if net_id in key:
282
+ student_key = key
283
+ break
284
+
285
+ if student_key:
286
+ deductions = student_deductions_obj.deductions_by_students[student_key]
287
+ for deduction in deductions:
288
+ item_deductions.append((deduction.message, deduction.points))
289
+ subitem_points_deducted += deduction.points
290
+
291
+ # Calculate item score
292
+ subitem_score = max(0, subitem_points_possible - subitem_points_deducted)
293
+ score_str = f"{subitem_score:.1f} / {subitem_points_possible:.1f}"
294
+
295
+ # Item line with score
296
+ item_name_with_colon = f"{item.name}:"
297
+ lines.append(
298
+ f"{item_name_with_colon:<{item_col_width}} {score_str:>{score_col_width}}"
299
+ )
300
+
301
+ # Deduction lines (indented)
302
+ for msg, pts in item_deductions:
303
+ # Wrap long messages
304
+ wrapped = _wrap_text(msg, deduction_msg_width)
305
+ for i, line_text in enumerate(wrapped):
306
+ if i == 0:
307
+ lines.append(
308
+ f"{deduction_prefix}{line_text:<{deduction_msg_width}} {-pts:>{deduction_pts_width}.1f}"
309
+ )
310
+ else:
311
+ # Continuation lines - no points
312
+ lines.append(f"{deduction_prefix}{line_text}")
313
+
314
+ total_points_deducted += subitem_points_deducted
315
+
316
+ # Calculate score before late penalty (clamped to 0)
317
+ score_before_late = max(0, total_points_possible - total_points_deducted)
318
+
319
+ # Get max late days for this student
320
+ _, max_late_days = _get_student_key_and_max_late_days(net_id, subitem_deductions)
321
+
322
+ # Late penalty section
323
+ lines.append("")
324
+ lines.append("=" * 60)
325
+ if max_late_days > 0 and late_penalty_callback:
326
+ final_score = late_penalty_callback(
327
+ max_late_days, total_points_possible, score_before_late
328
+ )
329
+ # Ensure final score is not negative
330
+ final_score = max(0, final_score)
331
+ late_penalty_points = score_before_late - final_score
332
+ late_label = (
333
+ f"Late Penalty ({max_late_days} day{'s' if max_late_days != 1 else ''}):"
334
+ )
335
+ lines.append(
336
+ f"{late_label:<{item_col_width}} {-late_penalty_points:>{score_col_width}.1f}"
337
+ )
338
+ else:
339
+ final_score = score_before_late
340
+ lines.append(
341
+ f"{'Late Penalty:':<{item_col_width}} {'On Time':>{score_col_width}}"
342
+ )
343
+
344
+ # Total score section
345
+ total_score_str = f"{final_score:.1f} / {total_points_possible:.1f}"
346
+ lines.append(
347
+ f"{'TOTAL SCORE:':<{item_col_width}} {total_score_str:>{score_col_width}}"
348
+ )
349
+ lines.append("=" * 60)
350
+
351
+ return "\n".join(lines)
352
+
353
+
354
+ def _wrap_text(text: str, width: int) -> list:
355
+ """Wrap text to fit within a given width.
356
+
357
+ Args:
358
+ text: The text to wrap.
359
+ width: Maximum width for each line.
360
+
361
+ Returns:
362
+ List of wrapped lines.
363
+ """
364
+ if len(text) <= width:
365
+ return [text]
366
+
367
+ words = text.split()
368
+ lines = []
369
+ current_line = ""
370
+
371
+ for word in words:
372
+ if not current_line:
373
+ current_line = word
374
+ elif len(current_line) + 1 + len(word) <= width:
375
+ current_line += " " + word
376
+ else:
377
+ lines.append(current_line)
378
+ current_line = word
379
+
380
+ if current_line:
381
+ lines.append(current_line)
382
+
383
+ return lines if lines else [""]
@@ -19,7 +19,7 @@ class LearningSuiteColumn:
19
19
  self.csv_col_name = None
20
20
  self.other_data = {}
21
21
 
22
- # First make sure the YAML file exists in a directory of the same name
22
+ # Make sure the YAML file exists
23
23
  if yaml_path.suffix != ".yaml":
24
24
  raise LearningSuiteColumnParseError(
25
25
  "The item_yaml_path must point to a .yaml file."
@@ -28,10 +28,6 @@ class LearningSuiteColumn:
28
28
  raise LearningSuiteColumnParseError(
29
29
  f"The specified YAML file does not exist: {yaml_path}"
30
30
  )
31
- if yaml_path.parent.name != yaml_path.stem:
32
- raise LearningSuiteColumnParseError(
33
- f"The YAML file must be located in a directory of the same name. Currently {yaml_path} is in {yaml_path.parent}"
34
- )
35
31
 
36
32
  with yaml_path.open("r") as f:
37
33
  data = yaml.safe_load(f)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ygrader
3
- Version: 2.0.0
3
+ Version: 2.1.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
@@ -1,218 +0,0 @@
1
- """Module for generating student feedback files."""
2
-
3
- import pathlib
4
- import zipfile
5
- from typing import Dict
6
-
7
- import pandas
8
-
9
- from .deductions import StudentDeductions
10
- from .grading_item_config import LearningSuiteColumn
11
-
12
-
13
- def generate_feedback_zip(
14
- yaml_path: pathlib.Path,
15
- class_list_csv_path: pathlib.Path,
16
- subitem_feedback_paths: Dict[str, pathlib.Path],
17
- output_zip_path: pathlib.Path = None,
18
- ) -> pathlib.Path:
19
- """Generate a zip file containing feedback files for each student.
20
-
21
- Args:
22
- yaml_path: Path to the YAML file that can be loaded by LearningSuiteColumn.
23
- class_list_csv_path: Path to CSV file with class list (Net ID, First Name, Last Name).
24
- subitem_feedback_paths: Mapping from subitem name to feedback YAML file path.
25
- output_zip_path: Path for the output zip file. If None, defaults to
26
- <yaml_dir>/feedback.zip.
27
-
28
- Returns:
29
- Path to the generated zip file.
30
- """
31
- yaml_path = pathlib.Path(yaml_path)
32
- ls_column = LearningSuiteColumn(yaml_path)
33
-
34
- # Get the lab name from the YAML file's parent directory
35
- lab_name = yaml_path.stem
36
-
37
- # Default output path
38
- if output_zip_path is None:
39
- output_zip_path = yaml_path.parent / "feedback.zip"
40
-
41
- # Load all student deductions for each subitem
42
- subitem_deductions: Dict[str, StudentDeductions] = {}
43
- for subitem in ls_column.items:
44
- if subitem.name in subitem_feedback_paths:
45
- feedback_path = subitem_feedback_paths[subitem.name]
46
- if feedback_path.exists():
47
- subitem_deductions[subitem.name] = StudentDeductions(feedback_path)
48
- else:
49
- subitem_deductions[subitem.name] = StudentDeductions()
50
- else:
51
- subitem_deductions[subitem.name] = StudentDeductions()
52
-
53
- # Load class list from provided CSV
54
- students_df = pandas.read_csv(class_list_csv_path)
55
-
56
- # Create the zip file
57
- with zipfile.ZipFile(output_zip_path, "w", zipfile.ZIP_DEFLATED) as zip_file:
58
- for _, student_row in students_df.iterrows():
59
- first_name = str(student_row["First Name"]).strip()
60
- last_name = str(student_row["Last Name"]).strip()
61
- net_id = str(student_row["Net ID"]).strip()
62
-
63
- # Generate feedback content for this student
64
- feedback_content = _generate_student_feedback(
65
- student_row=student_row,
66
- ls_column=ls_column,
67
- subitem_deductions=subitem_deductions,
68
- )
69
-
70
- # Generate filename
71
- filename = (
72
- first_name
73
- + "_"
74
- + last_name
75
- + "_"
76
- + net_id
77
- + "_feedback-"
78
- + lab_name
79
- + ".txt"
80
- )
81
-
82
- # Add to zip file
83
- zip_file.writestr(filename, feedback_content)
84
-
85
- return output_zip_path
86
-
87
-
88
- def _generate_student_feedback(
89
- student_row: pandas.Series,
90
- ls_column: LearningSuiteColumn,
91
- subitem_deductions: Dict[str, StudentDeductions],
92
- ) -> str:
93
- """Generate the feedback text content for a single student.
94
-
95
- Args:
96
- student_row: A row from the student DataFrame.
97
- ls_column: The LearningSuiteColumn object.
98
- subitem_deductions: Mapping from subitem name to StudentDeductions.
99
-
100
- Returns:
101
- The formatted feedback text.
102
- """
103
- net_id = str(student_row["Net ID"]).strip()
104
- first_name = str(student_row["First Name"]).strip()
105
- last_name = str(student_row["Last Name"]).strip()
106
-
107
- lines = []
108
- lines.append(f"Feedback for {first_name} {last_name} ({net_id})")
109
- lines.append("=" * 60)
110
- lines.append("")
111
-
112
- total_points_possible = 0
113
- total_points_deducted = 0
114
-
115
- # Column widths for formatting
116
- feedback_col_width = 45
117
- points_col_width = 15
118
-
119
- for item in ls_column.items:
120
- subitem_points_possible = item.points
121
- total_points_possible += subitem_points_possible
122
-
123
- lines.append(f"{item.name} ({subitem_points_possible} points)")
124
- lines.append("-" * 60)
125
-
126
- # Header row
127
- header = f"{'Feedback':<{feedback_col_width}} {'Points Deducted':>{points_col_width}}"
128
- lines.append(header)
129
- lines.append("-" * 60)
130
-
131
- subitem_points_deducted = 0
132
-
133
- # Get deductions for this student in this item
134
- student_deductions_obj = subitem_deductions.get(item.name)
135
- if student_deductions_obj:
136
- # Find the student's deductions (try single net_id first, then tuple)
137
- student_key = None
138
- if (net_id,) in student_deductions_obj.deductions_by_students:
139
- student_key = (net_id,)
140
- else:
141
- # Check for multi-student keys containing this net_id
142
- for key in student_deductions_obj.deductions_by_students.keys():
143
- if net_id in key:
144
- student_key = key
145
- break
146
-
147
- if student_key:
148
- deductions = student_deductions_obj.deductions_by_students[student_key]
149
- for deduction in deductions:
150
- feedback_text = deduction.message
151
- points = deduction.points
152
-
153
- # Wrap long feedback text
154
- wrapped_lines = _wrap_text(feedback_text, feedback_col_width)
155
- for i, line_text in enumerate(wrapped_lines):
156
- if i == 0:
157
- row = f"{line_text:<{feedback_col_width}} {points:>{points_col_width}.1f}"
158
- else:
159
- row = f"{line_text:<{feedback_col_width}} {'':{points_col_width}}"
160
- lines.append(row)
161
-
162
- subitem_points_deducted += points
163
-
164
- if subitem_points_deducted == 0:
165
- lines.append(
166
- f"{'No deductions':<{feedback_col_width}} {'0.0':>{points_col_width}}"
167
- )
168
-
169
- lines.append("-" * 60)
170
- subitem_score = subitem_points_possible - subitem_points_deducted
171
- lines.append(
172
- f"{'Subitem Total:':<{feedback_col_width}} {subitem_score:>{points_col_width}.1f} / {subitem_points_possible:.1f}"
173
- )
174
- lines.append("")
175
-
176
- total_points_deducted += subitem_points_deducted
177
-
178
- # Total score section
179
- lines.append("=" * 60)
180
- total_score = total_points_possible - total_points_deducted
181
- lines.append(
182
- f"{'TOTAL SCORE:':<{feedback_col_width}} {total_score:>{points_col_width}.1f} / {total_points_possible:.1f}"
183
- )
184
- lines.append("=" * 60)
185
-
186
- return "\n".join(lines)
187
-
188
-
189
- def _wrap_text(text: str, width: int) -> list:
190
- """Wrap text to fit within a given width.
191
-
192
- Args:
193
- text: The text to wrap.
194
- width: Maximum width for each line.
195
-
196
- Returns:
197
- List of wrapped lines.
198
- """
199
- if len(text) <= width:
200
- return [text]
201
-
202
- words = text.split()
203
- lines = []
204
- current_line = ""
205
-
206
- for word in words:
207
- if not current_line:
208
- current_line = word
209
- elif len(current_line) + 1 + len(word) <= width:
210
- current_line += " " + word
211
- else:
212
- lines.append(current_line)
213
- current_line = word
214
-
215
- if current_line:
216
- lines.append(current_line)
217
-
218
- return lines if lines else [""]
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