QuizGenerator 0.4.2__py3-none-any.whl

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 (52) hide show
  1. QuizGenerator/README.md +5 -0
  2. QuizGenerator/__init__.py +27 -0
  3. QuizGenerator/__main__.py +7 -0
  4. QuizGenerator/canvas/__init__.py +13 -0
  5. QuizGenerator/canvas/canvas_interface.py +627 -0
  6. QuizGenerator/canvas/classes.py +235 -0
  7. QuizGenerator/constants.py +149 -0
  8. QuizGenerator/contentast.py +1955 -0
  9. QuizGenerator/generate.py +253 -0
  10. QuizGenerator/logging.yaml +55 -0
  11. QuizGenerator/misc.py +579 -0
  12. QuizGenerator/mixins.py +548 -0
  13. QuizGenerator/performance.py +202 -0
  14. QuizGenerator/premade_questions/__init__.py +0 -0
  15. QuizGenerator/premade_questions/basic.py +103 -0
  16. QuizGenerator/premade_questions/cst334/__init__.py +1 -0
  17. QuizGenerator/premade_questions/cst334/languages.py +391 -0
  18. QuizGenerator/premade_questions/cst334/math_questions.py +297 -0
  19. QuizGenerator/premade_questions/cst334/memory_questions.py +1400 -0
  20. QuizGenerator/premade_questions/cst334/ostep13_vsfs.py +572 -0
  21. QuizGenerator/premade_questions/cst334/persistence_questions.py +451 -0
  22. QuizGenerator/premade_questions/cst334/process.py +648 -0
  23. QuizGenerator/premade_questions/cst463/__init__.py +0 -0
  24. QuizGenerator/premade_questions/cst463/gradient_descent/__init__.py +3 -0
  25. QuizGenerator/premade_questions/cst463/gradient_descent/gradient_calculation.py +369 -0
  26. QuizGenerator/premade_questions/cst463/gradient_descent/gradient_descent_questions.py +305 -0
  27. QuizGenerator/premade_questions/cst463/gradient_descent/loss_calculations.py +650 -0
  28. QuizGenerator/premade_questions/cst463/gradient_descent/misc.py +73 -0
  29. QuizGenerator/premade_questions/cst463/math_and_data/__init__.py +2 -0
  30. QuizGenerator/premade_questions/cst463/math_and_data/matrix_questions.py +631 -0
  31. QuizGenerator/premade_questions/cst463/math_and_data/vector_questions.py +534 -0
  32. QuizGenerator/premade_questions/cst463/models/__init__.py +0 -0
  33. QuizGenerator/premade_questions/cst463/models/attention.py +192 -0
  34. QuizGenerator/premade_questions/cst463/models/cnns.py +186 -0
  35. QuizGenerator/premade_questions/cst463/models/matrices.py +24 -0
  36. QuizGenerator/premade_questions/cst463/models/rnns.py +202 -0
  37. QuizGenerator/premade_questions/cst463/models/text.py +203 -0
  38. QuizGenerator/premade_questions/cst463/models/weight_counting.py +227 -0
  39. QuizGenerator/premade_questions/cst463/neural-network-basics/__init__.py +6 -0
  40. QuizGenerator/premade_questions/cst463/neural-network-basics/neural_network_questions.py +1314 -0
  41. QuizGenerator/premade_questions/cst463/tensorflow-intro/__init__.py +6 -0
  42. QuizGenerator/premade_questions/cst463/tensorflow-intro/tensorflow_questions.py +936 -0
  43. QuizGenerator/qrcode_generator.py +293 -0
  44. QuizGenerator/question.py +715 -0
  45. QuizGenerator/quiz.py +467 -0
  46. QuizGenerator/regenerate.py +472 -0
  47. QuizGenerator/typst_utils.py +113 -0
  48. quizgenerator-0.4.2.dist-info/METADATA +265 -0
  49. quizgenerator-0.4.2.dist-info/RECORD +52 -0
  50. quizgenerator-0.4.2.dist-info/WHEEL +4 -0
  51. quizgenerator-0.4.2.dist-info/entry_points.txt +3 -0
  52. quizgenerator-0.4.2.dist-info/licenses/LICENSE +674 -0
@@ -0,0 +1,5 @@
1
+
2
+
3
+ ## Installation
4
+
5
+ Note, you will need to install `pandoc` prior to running since it is used to convert to HTML (for canvas) and Latex (for PDF)`
@@ -0,0 +1,27 @@
1
+
2
+ import logging.config
3
+ import yaml
4
+ import os
5
+ import re
6
+
7
+ def setup_logging() -> None:
8
+ config_path = os.path.join(os.path.dirname(__file__), 'logging.yaml')
9
+ if os.path.exists(config_path):
10
+ with open(config_path, 'r') as f:
11
+ config_text = f.read()
12
+
13
+ # Process environment variables in the format ${VAR:-default}
14
+ def replace_env_vars(match) -> str:
15
+ var_name = match.group(1)
16
+ default_value = match.group(2)
17
+ return os.environ.get(var_name, default_value)
18
+
19
+ config_text = re.sub(r'\$\{([^}:]+):-([^}]+)\}', replace_env_vars, config_text)
20
+ config = yaml.safe_load(config_text)
21
+ logging.config.dictConfig(config)
22
+ else:
23
+ # Fallback to basic configuration if logging.yaml is not found
24
+ logging.basicConfig(level=logging.INFO)
25
+
26
+ # Call this once when your application starts
27
+ setup_logging()
@@ -0,0 +1,7 @@
1
+ """
2
+ Allow running QuizGenerator as a module: python -m QuizGenerator
3
+ """
4
+ from QuizGenerator.generate import main
5
+
6
+ if __name__ == "__main__":
7
+ main()
@@ -0,0 +1,13 @@
1
+ """
2
+ Canvas LMS integration for QuizGenerator.
3
+
4
+ Vendored from LMSInterface v0.1.0 (2025-11-24)
5
+
6
+ This module provides Canvas API integration for uploading quizzes
7
+ and managing course content.
8
+ """
9
+
10
+ __version__ = "0.1.0"
11
+ __vendored_from__ = "LMSInterface"
12
+ __vendored_date__ = "2025-11-24"
13
+
@@ -0,0 +1,627 @@
1
+ #!env python
2
+ from __future__ import annotations
3
+
4
+ import queue
5
+ import tempfile
6
+ import time
7
+ import typing
8
+ from datetime import datetime, timezone
9
+ from typing import List, Optional
10
+
11
+ import canvasapi
12
+ import canvasapi.course
13
+ import canvasapi.quiz
14
+ import canvasapi.assignment
15
+ import canvasapi.submission
16
+ import canvasapi.exceptions
17
+ import dotenv, os
18
+ import requests
19
+ from canvasapi.util import combine_kwargs
20
+
21
+ try:
22
+ from urllib3.util.retry import Retry # urllib3 v2
23
+ except Exception:
24
+ from urllib3.util import Retry # urllib3 v1 fallback
25
+
26
+ import os
27
+ import dotenv
28
+
29
+ from .classes import LMSWrapper, Student, Submission, Submission__Canvas, FileSubmission__Canvas, TextSubmission__Canvas, QuizSubmission
30
+
31
+ import logging
32
+
33
+ log = logging.getLogger(__name__)
34
+
35
+ QUESTION_VARIATIONS_TO_TRY = 1000
36
+ NUM_WORKERS = 4
37
+
38
+
39
+ class CanvasInterface:
40
+ def __init__(self, *, prod=False):
41
+ dotenv.load_dotenv(os.path.join(os.path.expanduser("~"), ".env"))
42
+
43
+ self.prod = prod
44
+ if self.prod:
45
+ log.warning("Using canvas PROD!")
46
+ self.canvas_url = os.environ.get("CANVAS_API_URL_prod")
47
+ self.canvas_key = os.environ.get("CANVAS_API_KEY_prod")
48
+ else:
49
+ log.info("Using canvas DEV")
50
+ self.canvas_url = os.environ.get("CANVAS_API_URL")
51
+ self.canvas_key = os.environ.get("CANVAS_API_KEY")
52
+
53
+ # Monkeypatch BEFORE constructing Canvas so all children use RobustRequester.
54
+ # cap_req.Requester = RobustRequester
55
+ # cap_canvas.Requester = RobustRequester
56
+ self.canvas = canvasapi.Canvas(self.canvas_url, self.canvas_key)
57
+
58
+ def get_course(self, course_id: int) -> CanvasCourse:
59
+ return CanvasCourse(
60
+ canvas_interface = self,
61
+ canvasapi_course = self.canvas.get_course(course_id)
62
+ )
63
+
64
+
65
+ class CanvasCourse(LMSWrapper):
66
+ def __init__(self, *args, canvas_interface : CanvasInterface, canvasapi_course : canvasapi.course.Course, **kwargs):
67
+ self.canvas_interface = canvas_interface
68
+ self.course = canvasapi_course
69
+ super().__init__(_inner=self.course)
70
+
71
+ def create_assignment_group(self, name="dev", delete_existing=False) -> canvasapi.course.AssignmentGroup:
72
+ for assignment_group in self.course.get_assignment_groups():
73
+ if assignment_group.name == name:
74
+ if delete_existing:
75
+ assignment_group.delete()
76
+ break
77
+ log.info("Found group existing, returning")
78
+ return assignment_group
79
+ assignment_group = self.course.create_assignment_group(
80
+ name=name,
81
+ group_weight=0.0,
82
+ position=0,
83
+ )
84
+ return assignment_group
85
+
86
+ def add_quiz(
87
+ self,
88
+ assignment_group: canvasapi.course.AssignmentGroup,
89
+ title = None,
90
+ *,
91
+ is_practice=False,
92
+ description=None
93
+ ):
94
+ if title is None:
95
+ title = f"New Quiz {datetime.now().strftime('%m/%d/%y %H:%M:%S.%f')}"
96
+
97
+ if description is None:
98
+ description = """
99
+ This quiz is aimed to help you practice skills.
100
+ Please take it as many times as necessary to get full marks!
101
+ Please note that although the answers section may be a bit lengthy,
102
+ below them is often an in-depth explanation on solving the problem!
103
+ """
104
+
105
+ q = self.course.create_quiz(quiz={
106
+ "title": title,
107
+ "hide_results" : None,
108
+ "show_correct_answers": True,
109
+ "scoring_policy": "keep_highest",
110
+ "allowed_attempts": -1,
111
+ "shuffle_answers": True,
112
+ "assignment_group_id": assignment_group.id,
113
+ "quiz_type" : "assignment" if not is_practice else "practice_quiz",
114
+ "description": description
115
+ })
116
+ return q
117
+
118
+ def push_quiz_to_canvas(
119
+ self,
120
+ quiz: Quiz,
121
+ num_variations: int,
122
+ title: typing.Optional[str] = None,
123
+ is_practice = False,
124
+ assignment_group: typing.Optional[canvasapi.course.AssignmentGroup] = None
125
+ ):
126
+ if assignment_group is None:
127
+ assignment_group = self.create_assignment_group()
128
+ canvas_quiz = self.add_quiz(assignment_group, title, is_practice=is_practice, description=quiz.description)
129
+
130
+ total_questions = len(quiz.questions)
131
+ total_variations_created = 0
132
+ log.info(f"Starting to push quiz '{title or canvas_quiz.title}' with {total_questions} questions to Canvas")
133
+ log.info(f"Target: {num_variations} variations per question")
134
+
135
+ all_variations = set() # Track all variations so we can ensure we aren't uploading duplicates
136
+ questions_to_upload = queue.Queue() # Make a queue of questions to upload so we can do so in the background
137
+
138
+ # Generate all quiz questions
139
+ for question_i, question in enumerate(quiz):
140
+ log.info(f"Processing question {question_i + 1}/{total_questions}: '{question.name}'")
141
+
142
+ group : canvasapi.quiz.QuizGroup = canvas_quiz.create_question_group([
143
+ {
144
+ "name": f"{question.name}",
145
+ "pick_count": 1,
146
+ "question_points": question.points_value
147
+ }
148
+ ])
149
+
150
+ # Track all variations across every question, in case we have duplicate questions
151
+ variation_count = 0
152
+ for attempt_number in range(QUESTION_VARIATIONS_TO_TRY):
153
+
154
+ # Get the question in a format that is ready for canvas (e.g. json)
155
+ # Use large gaps between base seeds to avoid overlap with backoff attempts
156
+ # Each variation gets seeds: base_seed, base_seed+1, base_seed+2, ... for backoffs
157
+ base_seed = attempt_number * 1000
158
+ question_for_canvas = question.get__canvas(self.course, canvas_quiz, rng_seed=base_seed)
159
+
160
+ question_fingerprint = question_for_canvas["question_text"]
161
+ try:
162
+ question_fingerprint += ''.join([
163
+ '|'.join([
164
+ f"{k}:{a[k]}" for k in sorted(a.keys())
165
+ ])
166
+ for a in question_for_canvas["answers"]
167
+ ])
168
+ except TypeError as e:
169
+ log.error(e)
170
+ log.warning("Continuing anyway")
171
+
172
+ # if it is in the variations that we have already seen then skip ahead, else track
173
+ if question_fingerprint in all_variations:
174
+ continue
175
+ all_variations.add(question_fingerprint)
176
+
177
+ # Push question to canvas
178
+ log.info(f"Creating #{question_i} ({question.name}) {variation_count + 1} / {num_variations} for canvas.")
179
+
180
+ # Set group ID to add it to the question group
181
+ question_for_canvas["quiz_group_id"] = group.id
182
+
183
+ questions_to_upload.put(question_for_canvas)
184
+ total_variations_created += 1
185
+
186
+ # Update and check variations already seen
187
+ variation_count += 1
188
+ if variation_count >= num_variations:
189
+ break
190
+ if variation_count >= question.possible_variations:
191
+ break
192
+
193
+ log.info(f"Completed question '{question.name}': {variation_count} variations created")
194
+
195
+ # Upload questions
196
+ num_questions_to_upload = questions_to_upload.qsize()
197
+ while not questions_to_upload.empty():
198
+ q_to_upload = questions_to_upload.get()
199
+ log.info(f"Uploading {num_questions_to_upload-questions_to_upload.qsize()} / {num_questions_to_upload} to canvas!")
200
+ try:
201
+ canvas_quiz.create_question(question=q_to_upload)
202
+ except canvasapi.exceptions.CanvasException as e:
203
+ log.warning("Encountered Canvas error.")
204
+ log.warning(e)
205
+ questions_to_upload.put(q_to_upload)
206
+ log.warning("Sleeping for 1s...")
207
+ time.sleep(1)
208
+ continue
209
+
210
+ log.info(f"Quiz upload completed! Total variations created: {total_variations_created}")
211
+ log.info(f"Canvas quiz URL: {canvas_quiz.html_url}")
212
+
213
+ def get_assignment(self, assignment_id : int) -> Optional[CanvasAssignment]:
214
+ try:
215
+ return CanvasAssignment(
216
+ canvasapi_interface=self.canvas_interface,
217
+ canvasapi_course=self,
218
+ canvasapi_assignment=self.course.get_assignment(assignment_id)
219
+ )
220
+ except canvasapi.exceptions.ResourceDoesNotExist:
221
+ log.error(f"Assignment {assignment_id} not found in course \"{self.name}\"")
222
+ return None
223
+
224
+ def get_assignments(self, **kwargs) -> List[CanvasAssignment]:
225
+ assignments : List[CanvasAssignment] = []
226
+ for canvasapi_assignment in self.course.get_assignments(**kwargs):
227
+ assignments.append(
228
+ CanvasAssignment(
229
+ canvasapi_interface=self.canvas_interface,
230
+ canvasapi_course=self,
231
+ canvasapi_assignment=canvasapi_assignment
232
+ )
233
+ )
234
+
235
+ assignments = self.course.get_assignments(**kwargs)
236
+ return assignments
237
+
238
+ def get_username(self, user_id: int):
239
+ return self.course.get_user(user_id).name
240
+
241
+ def get_students(self) -> List[Student]:
242
+ return [Student(s.name, s.id, s) for s in self.course.get_users(enrollment_type=["student"])]
243
+
244
+ def get_quiz(self, quiz_id: int) -> Optional[CanvasQuiz]:
245
+ """Get a specific quiz by ID"""
246
+ try:
247
+ return CanvasQuiz(
248
+ canvas_interface=self.canvas_interface,
249
+ canvasapi_course=self,
250
+ canvasapi_quiz=self.course.get_quiz(quiz_id)
251
+ )
252
+ except canvasapi.exceptions.ResourceDoesNotExist:
253
+ log.error(f"Quiz {quiz_id} not found in course \"{self.name}\"")
254
+ return None
255
+
256
+ def get_quizzes(self, **kwargs) -> List[CanvasQuiz]:
257
+ """Get all quizzes in the course"""
258
+ quizzes: List[CanvasQuiz] = []
259
+ for canvasapi_quiz in self.course.get_quizzes(**kwargs):
260
+ quizzes.append(
261
+ CanvasQuiz(
262
+ canvas_interface=self.canvas_interface,
263
+ canvasapi_course=self,
264
+ canvasapi_quiz=canvasapi_quiz
265
+ )
266
+ )
267
+ return quizzes
268
+
269
+
270
+ class CanvasAssignment(LMSWrapper):
271
+ def __init__(self, *args, canvasapi_interface: CanvasInterface, canvasapi_course : CanvasCourse, canvasapi_assignment: canvasapi.assignment.Assignment, **kwargs):
272
+ self.canvas_interface = canvasapi_interface
273
+ self.canvas_course = canvasapi_course
274
+ self.assignment = canvasapi_assignment
275
+ super().__init__(_inner=canvasapi_assignment)
276
+
277
+ def push_feedback(self, user_id, score: float, comments: str, attachments=None, keep_previous_best=True, clobber_feedback=False):
278
+ log.debug(f"Adding feedback for {user_id}")
279
+ if attachments is None:
280
+ attachments = []
281
+
282
+ # Get the previous score to check to see if we should reuse it
283
+ try:
284
+ submission = self.assignment.get_submission(user_id)
285
+ if keep_previous_best and score is not None and submission.score is not None and submission.score > score:
286
+ log.warning(f"Current score ({submission.score}) higher than new score ({score}). Going to use previous score.")
287
+ score = submission.score
288
+ except requests.exceptions.ConnectionError as e:
289
+ log.warning(f"No previous submission found for {user_id}")
290
+
291
+ # Update the assignment
292
+ # Note: the bulk_update will create a submission if none exists
293
+ try:
294
+ self.assignment.submissions_bulk_update(
295
+ grade_data={
296
+ 'submission[posted_grade]' : score
297
+ },
298
+ student_ids=[user_id]
299
+ )
300
+
301
+ submission = self.assignment.get_submission(user_id)
302
+ except requests.exceptions.ConnectionError as e:
303
+ log.error(e)
304
+ log.debug(f"Failed on user_id = {user_id})")
305
+ log.debug(f"username: {self.canvas_course.get_user(user_id)}")
306
+ return
307
+
308
+ # Push feedback to canvas
309
+ submission.edit(
310
+ submission={
311
+ 'posted_grade':score,
312
+ },
313
+ )
314
+
315
+ # If we should overwrite previous comments then remove all the previous submissions
316
+ if clobber_feedback:
317
+ log.debug("Clobbering...")
318
+ # todo: clobbering should probably be moved up or made into a different function for cleanliness.
319
+ for comment in submission.submission_comments:
320
+ comment_id = comment['id']
321
+
322
+ # Construct the URL to delete the comment
323
+ api_path = f"/api/v1/courses/{self.canvas_course.course.id}/assignments/{self.assignment.id}/submissions/{user_id}/comments/{comment_id}"
324
+ response = self.canvas_interface.canvas._Canvas__requester.request("DELETE", api_path)
325
+ if response.status_code == 200:
326
+ log.info(f"Deleted comment {comment_id}")
327
+ else:
328
+ log.warning(f"Failed to delete comment {comment_id}: {response.json()}")
329
+
330
+ def upload_buffer_as_file(buffer: bytes, name: str):
331
+ suffix = os.path.splitext(name)[1] # keep extension if needed
332
+ with tempfile.NamedTemporaryFile(mode="wb", delete=False, dir=".", prefix="feedback_", suffix=suffix) as tmp:
333
+ tmp.write(buffer)
334
+ tmp.flush()
335
+ os.fsync(tmp.fileno())
336
+ temp_path = tmp.name # str path
337
+
338
+ try:
339
+ submission.upload_comment(temp_path) # ✅ PathLike | str
340
+ finally:
341
+ os.remove(temp_path)
342
+
343
+ if len(comments) > 0:
344
+ upload_buffer_as_file(comments.encode('utf-8'), "feedback.txt")
345
+
346
+ for i, attachment_buffer in enumerate(attachments):
347
+ upload_buffer_as_file(attachment_buffer.read(), attachment_buffer.name)
348
+
349
+ def get_submissions(self, only_include_most_recent: bool = True, **kwargs) -> List[Submission]:
350
+ """
351
+ Gets submission objects (in this case Submission__Canvas objects) that have students and potentially attachments
352
+ :param only_include_most_recent: Include only the most recent submission
353
+ :param kwargs:
354
+ :return:
355
+ """
356
+
357
+ if "limit" in kwargs and kwargs["limit"] is not None:
358
+ limit = kwargs["limit"]
359
+ else:
360
+ limit = 1_000_000 # magically large number
361
+
362
+ test_only = kwargs.get("test", False)
363
+
364
+ submissions: List[Submission] = []
365
+
366
+ # Get all submissions and their history (which is necessary for attachments when students can resubmit)
367
+ for student_index, canvaspai_submission in enumerate(self.assignment.get_submissions(include='submission_history', **kwargs)):
368
+
369
+ # Get the student object for the submission
370
+ student = Student(
371
+ self.canvas_course.get_username(canvaspai_submission.user_id),
372
+ user_id=canvaspai_submission.user_id,
373
+ _inner=self.canvas_course.get_user(canvaspai_submission.user_id)
374
+ )
375
+
376
+ if test_only and not "Test Student" in student.name:
377
+ continue
378
+
379
+ log.debug(f"Checking submissions for {student.name} ({len(canvaspai_submission.submission_history)} submissions)")
380
+
381
+ # Walk through submissions in the reverse order, so we'll default to grabbing the most recent submission first
382
+ # This is important when we are going to be only including most recent
383
+ for student_submission_index, student_submission in (
384
+ reversed(list(enumerate(canvaspai_submission.submission_history)))):
385
+ log.debug(f"Submission: {student_submission['workflow_state']} " +
386
+ (f"{student_submission['score']:0.2f}" if student_submission['score'] is not None else "None"))
387
+
388
+ # Determine submission type based on content
389
+ has_attachments = student_submission.get("attachments") is not None and len(student_submission.get("attachments", [])) > 0
390
+ has_text_body = student_submission.get("body") is not None and student_submission.get("body").strip() != ""
391
+
392
+ if has_text_body:
393
+ # Text submission - create object-like structure from dict
394
+ log.debug(f"Detected text submission for {student.name}")
395
+ class SubmissionObject:
396
+ def __init__(self, data):
397
+ for key, value in data.items():
398
+ setattr(self, key, value)
399
+
400
+ submissions.append(
401
+ TextSubmission__Canvas(
402
+ student=student,
403
+ status=Submission.Status.from_string(student_submission["workflow_state"], student_submission['score']),
404
+ canvas_submission_data=SubmissionObject(student_submission),
405
+ submission_index=student_submission_index
406
+ )
407
+ )
408
+ elif has_attachments:
409
+ # File submission
410
+ log.debug(f"Detected file submission for {student.name}")
411
+ submissions.append(
412
+ FileSubmission__Canvas(
413
+ student=student,
414
+ status=Submission.Status.from_string(student_submission["workflow_state"], student_submission['score']),
415
+ attachments=student_submission["attachments"],
416
+ submission_index=student_submission_index
417
+ )
418
+ )
419
+ else:
420
+ # No submission content found
421
+ log.debug(f"No submission content found for {student.name}")
422
+ continue
423
+
424
+ # Check if we should only include the most recent
425
+ if only_include_most_recent: break
426
+
427
+ # Check if we are limiting how many students we are checking
428
+ if student_index >= (limit - 1): break
429
+
430
+ # Reverse the submissions again so we are preserving temporal order. This isn't necessary but makes me feel happy.
431
+ submissions = list(reversed(submissions))
432
+ return submissions
433
+
434
+ def get_students(self):
435
+ return self.canvas_course.get_students()
436
+
437
+
438
+ class CanvasQuiz(LMSWrapper):
439
+ """Canvas quiz interface for handling quiz submissions and responses"""
440
+
441
+ def __init__(self, *args, canvas_interface: CanvasInterface, canvasapi_course: CanvasCourse, canvasapi_quiz: canvasapi.quiz.Quiz, **kwargs):
442
+ self.canvas_interface = canvas_interface
443
+ self.canvas_course = canvasapi_course
444
+ self.quiz = canvasapi_quiz
445
+ super().__init__(_inner=canvasapi_quiz)
446
+
447
+ def get_quiz_submissions(self, **kwargs) -> List[QuizSubmission]:
448
+ """
449
+ Get all quiz submissions with student responses
450
+ :param kwargs: Additional parameters for filtering
451
+ :return: List of QuizSubmission objects
452
+ """
453
+ test_only = kwargs.get("test", False)
454
+ limit = kwargs.get("limit", 1_000_000)
455
+
456
+ quiz_submissions: List[QuizSubmission] = []
457
+
458
+ # Get all quiz submissions
459
+ for student_index, canvasapi_quiz_submission in enumerate(self.quiz.get_submissions(**kwargs)):
460
+
461
+ # Get the student object for the submission
462
+ try:
463
+ student = Student(
464
+ self.canvas_course.get_username(canvasapi_quiz_submission.user_id),
465
+ user_id=canvasapi_quiz_submission.user_id,
466
+ _inner=self.canvas_course.get_user(canvasapi_quiz_submission.user_id)
467
+ )
468
+ except Exception as e:
469
+ log.warning(f"Could not get student info for user_id {canvasapi_quiz_submission.user_id}: {e}")
470
+ continue
471
+
472
+ if test_only and "Test Student" not in student.name:
473
+ continue
474
+
475
+ log.debug(f"Processing quiz submission for {student.name}")
476
+
477
+ # Get detailed submission responses
478
+ try:
479
+ submission_questions = canvasapi_quiz_submission.get_submission_questions()
480
+
481
+ # Convert to our format: question_id -> response
482
+ student_responses = {}
483
+ quiz_questions = {}
484
+
485
+ for question in submission_questions:
486
+ question_id = question.id
487
+ student_responses[question_id] = {
488
+ 'answer': question.answer,
489
+ 'correct': getattr(question, 'correct', None),
490
+ 'points': getattr(question, 'points', 0),
491
+ 'question_type': getattr(question, 'question_type', 'unknown')
492
+ }
493
+
494
+ # Store question metadata
495
+ quiz_questions[question_id] = {
496
+ 'question_name': getattr(question, 'question_name', ''),
497
+ 'question_text': getattr(question, 'question_text', ''),
498
+ 'question_type': getattr(question, 'question_type', 'unknown'),
499
+ 'points_possible': getattr(question, 'points_possible', 0)
500
+ }
501
+
502
+ # Create QuizSubmission object
503
+ quiz_submission = QuizSubmission(
504
+ student=student,
505
+ status=Submission.Status.from_string(canvasapi_quiz_submission.workflow_state, canvasapi_quiz_submission.percentage_score),
506
+ quiz_submission_data=canvasapi_quiz_submission,
507
+ student_responses=student_responses,
508
+ quiz_questions=quiz_questions
509
+ )
510
+
511
+ quiz_submissions.append(quiz_submission)
512
+
513
+ except Exception as e:
514
+ log.error(f"Failed to get submission questions for {student.name}: {e}")
515
+ continue
516
+
517
+ # Check if we are limiting how many students we are checking
518
+ if student_index >= (limit - 1):
519
+ break
520
+
521
+ return quiz_submissions
522
+
523
+ def get_questions(self):
524
+ """Get all quiz questions"""
525
+ return self.quiz.get_questions()
526
+
527
+ def push_feedback(self, user_id, score: float, comments: str, **kwargs):
528
+ """
529
+ Push feedback for a quiz submission
530
+ Note: Quiz feedback mechanisms may be different from assignment feedback
531
+ """
532
+ # Quiz submissions typically don't support the same feedback mechanisms as assignments
533
+ # This is a placeholder for quiz-specific feedback handling
534
+ log.warning("Quiz feedback pushing not yet implemented")
535
+ pass
536
+
537
+
538
+ class CanvasHelpers:
539
+ @staticmethod
540
+ def get_closed_assignments(interface: CanvasCourse) -> List[canvasapi.assignment.Assignment]:
541
+ closed_assignments : List[canvasapi.assignment.Assignment] = []
542
+ for assignment in interface.get_assignments(
543
+ include=["all_dates"],
544
+ order_by="name"
545
+ ):
546
+ if not assignment.published:
547
+ continue
548
+ if assignment.lock_at is not None:
549
+ # Then it's the easy case because there's no overrides
550
+ if datetime.fromisoformat(assignment.lock_at) < datetime.now(timezone.utc):
551
+ # Then the assignment is past due
552
+ closed_assignments.append(assignment)
553
+ continue
554
+ elif assignment.all_dates is not None:
555
+
556
+ # First we need to figure out what the latest time this assignment could be available is
557
+ # todo: This could be done on a per-student basis
558
+ last_lock_datetime = None
559
+ for dates_dict in assignment.all_dates:
560
+ if dates_dict["lock_at"] is not None:
561
+ lock_datetime = datetime.fromisoformat(dates_dict["lock_at"])
562
+ if (last_lock_datetime is None) or (lock_datetime >= last_lock_datetime):
563
+ last_lock_datetime = lock_datetime
564
+
565
+ # If we have found a valid lock time, and it's in the past then we lock
566
+ if last_lock_datetime is not None and last_lock_datetime <= datetime.now(timezone.utc):
567
+ closed_assignments.append(assignment)
568
+ continue
569
+
570
+ else:
571
+ log.warning(f"Cannot find any lock dates for assignment {assignment.name}!")
572
+
573
+ return closed_assignments
574
+
575
+ @staticmethod
576
+ def get_unsubmitted_submissions(interface: CanvasCourse, assignment: canvasapi.assignment.Assignment) -> List[canvasapi.submission.Submission]:
577
+ submissions : List[canvasapi.submission.Submission] = list(filter(
578
+ lambda s: s.submitted_at is None and s.percentage_score is None and not s.excused,
579
+ assignment.get_submissions()
580
+ ))
581
+ return submissions
582
+
583
+ @classmethod
584
+ def clear_out_missing(cls, interface: CanvasCourse):
585
+ assignments = cls.get_closed_assignments(interface)
586
+ for assignment in assignments:
587
+ missing_submissions = cls.get_unsubmitted_submissions(interface, assignment)
588
+ if not missing_submissions:
589
+ continue
590
+ log.info(f"Assignment: ({assignment.quiz_id if hasattr(assignment, 'quiz_id') else assignment.id}) {assignment.name} {assignment.published}")
591
+ for submission in missing_submissions:
592
+ log.info(f"{submission.user_id} ({interface.get_username(submission.user_id)}) : {submission.workflow_state} : {submission.missing} : {submission.score} : {submission.grader_id} : {submission.graded_at}")
593
+ submission.edit(submission={"late_policy_status" : "missing"})
594
+ log.info("")
595
+
596
+ @staticmethod
597
+ def deprecate_assignment(canvas_course: CanvasCourse, assignment_id) -> List[canvasapi.assignment.Assignment]:
598
+
599
+ log.debug(canvas_course.__dict__)
600
+
601
+ # for assignment in canvas_course.get_assignments():
602
+ # print(assignment)
603
+
604
+ canvas_assignment : CanvasAssignment = canvas_course.get_assignment(assignment_id=assignment_id)
605
+
606
+ canvas_assignment.assignment.edit(
607
+ assignment={
608
+ "name": f"{canvas_assignment.assignment.name} (deprecated)",
609
+ "due_at": f"{datetime.now(timezone.utc).isoformat()}",
610
+ "lock_at": f"{datetime.now(timezone.utc).isoformat()}"
611
+ }
612
+ )
613
+
614
+ @staticmethod
615
+ def mark_future_assignments_as_ungraded(canvas_course: CanvasCourse):
616
+
617
+ for assignment in canvas_course.get_assignments(
618
+ include=["all_dates"],
619
+ order_by="name"
620
+ ):
621
+ if assignment.unlock_at is not None:
622
+ if datetime.fromisoformat(assignment.unlock_at) > datetime.now(timezone.utc):
623
+ log.debug(assignment)
624
+ for submission in assignment.get_submissions():
625
+ submission.mark_unread()
626
+
627
+