QuizGenerator 0.1.0__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 (44) 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 +622 -0
  6. QuizGenerator/canvas/classes.py +235 -0
  7. QuizGenerator/constants.py +149 -0
  8. QuizGenerator/contentast.py +1809 -0
  9. QuizGenerator/generate.py +362 -0
  10. QuizGenerator/logging.yaml +55 -0
  11. QuizGenerator/misc.py +480 -0
  12. QuizGenerator/mixins.py +539 -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 +395 -0
  18. QuizGenerator/premade_questions/cst334/math_questions.py +297 -0
  19. QuizGenerator/premade_questions/cst334/memory_questions.py +1398 -0
  20. QuizGenerator/premade_questions/cst334/ostep13_vsfs.py +572 -0
  21. QuizGenerator/premade_questions/cst334/persistence_questions.py +396 -0
  22. QuizGenerator/premade_questions/cst334/process.py +649 -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/neural-network-basics/__init__.py +6 -0
  33. QuizGenerator/premade_questions/cst463/neural-network-basics/neural_network_questions.py +1264 -0
  34. QuizGenerator/premade_questions/cst463/tensorflow-intro/__init__.py +6 -0
  35. QuizGenerator/premade_questions/cst463/tensorflow-intro/tensorflow_questions.py +936 -0
  36. QuizGenerator/qrcode_generator.py +293 -0
  37. QuizGenerator/question.py +657 -0
  38. QuizGenerator/quiz.py +468 -0
  39. QuizGenerator/typst_utils.py +113 -0
  40. quizgenerator-0.1.0.dist-info/METADATA +263 -0
  41. quizgenerator-0.1.0.dist-info/RECORD +44 -0
  42. quizgenerator-0.1.0.dist-info/WHEEL +4 -0
  43. quizgenerator-0.1.0.dist-info/entry_points.txt +2 -0
  44. quizgenerator-0.1.0.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-18)
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-18"
13
+
@@ -0,0 +1,622 @@
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([str(a["answer_text"]) for a in question_for_canvas["answers"]])
163
+ except TypeError as e:
164
+ log.error(e)
165
+ log.warning("Continuing anyway")
166
+
167
+ # if it is in the variations that we have already seen then skip ahead, else track
168
+ if question_fingerprint in all_variations:
169
+ continue
170
+ all_variations.add(question_fingerprint)
171
+
172
+ # Push question to canvas
173
+ log.info(f"Creating #{question_i} ({question.name}) {variation_count + 1} / {num_variations} for canvas.")
174
+
175
+ # Set group ID to add it to the question group
176
+ question_for_canvas["quiz_group_id"] = group.id
177
+
178
+ questions_to_upload.put(question_for_canvas)
179
+ total_variations_created += 1
180
+
181
+ # Update and check variations already seen
182
+ variation_count += 1
183
+ if variation_count >= num_variations:
184
+ break
185
+ if variation_count >= question.possible_variations:
186
+ break
187
+
188
+ log.info(f"Completed question '{question.name}': {variation_count} variations created")
189
+
190
+ # Upload questions
191
+ num_questions_to_upload = questions_to_upload.qsize()
192
+ while not questions_to_upload.empty():
193
+ q_to_upload = questions_to_upload.get()
194
+ log.info(f"Uploading {num_questions_to_upload-questions_to_upload.qsize()} / {num_questions_to_upload} to canvas!")
195
+ try:
196
+ canvas_quiz.create_question(question=q_to_upload)
197
+ except canvasapi.exceptions.CanvasException as e:
198
+ log.warning("Encountered Canvas error.")
199
+ log.warning(e)
200
+ questions_to_upload.put(q_to_upload)
201
+ log.warning("Sleeping for 1s...")
202
+ time.sleep(1)
203
+ continue
204
+
205
+ log.info(f"Quiz upload completed! Total variations created: {total_variations_created}")
206
+ log.info(f"Canvas quiz URL: {canvas_quiz.html_url}")
207
+
208
+ def get_assignment(self, assignment_id : int) -> Optional[CanvasAssignment]:
209
+ try:
210
+ return CanvasAssignment(
211
+ canvasapi_interface=self.canvas_interface,
212
+ canvasapi_course=self,
213
+ canvasapi_assignment=self.course.get_assignment(assignment_id)
214
+ )
215
+ except canvasapi.exceptions.ResourceDoesNotExist:
216
+ log.error(f"Assignment {assignment_id} not found in course \"{self.name}\"")
217
+ return None
218
+
219
+ def get_assignments(self, **kwargs) -> List[CanvasAssignment]:
220
+ assignments : List[CanvasAssignment] = []
221
+ for canvasapi_assignment in self.course.get_assignments(**kwargs):
222
+ assignments.append(
223
+ CanvasAssignment(
224
+ canvasapi_interface=self.canvas_interface,
225
+ canvasapi_course=self,
226
+ canvasapi_assignment=canvasapi_assignment
227
+ )
228
+ )
229
+
230
+ assignments = self.course.get_assignments(**kwargs)
231
+ return assignments
232
+
233
+ def get_username(self, user_id: int):
234
+ return self.course.get_user(user_id).name
235
+
236
+ def get_students(self) -> List[Student]:
237
+ return [Student(s.name, s.id, s) for s in self.course.get_users(enrollment_type=["student"])]
238
+
239
+ def get_quiz(self, quiz_id: int) -> Optional[CanvasQuiz]:
240
+ """Get a specific quiz by ID"""
241
+ try:
242
+ return CanvasQuiz(
243
+ canvas_interface=self.canvas_interface,
244
+ canvasapi_course=self,
245
+ canvasapi_quiz=self.course.get_quiz(quiz_id)
246
+ )
247
+ except canvasapi.exceptions.ResourceDoesNotExist:
248
+ log.error(f"Quiz {quiz_id} not found in course \"{self.name}\"")
249
+ return None
250
+
251
+ def get_quizzes(self, **kwargs) -> List[CanvasQuiz]:
252
+ """Get all quizzes in the course"""
253
+ quizzes: List[CanvasQuiz] = []
254
+ for canvasapi_quiz in self.course.get_quizzes(**kwargs):
255
+ quizzes.append(
256
+ CanvasQuiz(
257
+ canvas_interface=self.canvas_interface,
258
+ canvasapi_course=self,
259
+ canvasapi_quiz=canvasapi_quiz
260
+ )
261
+ )
262
+ return quizzes
263
+
264
+
265
+ class CanvasAssignment(LMSWrapper):
266
+ def __init__(self, *args, canvasapi_interface: CanvasInterface, canvasapi_course : CanvasCourse, canvasapi_assignment: canvasapi.assignment.Assignment, **kwargs):
267
+ self.canvas_interface = canvasapi_interface
268
+ self.canvas_course = canvasapi_course
269
+ self.assignment = canvasapi_assignment
270
+ super().__init__(_inner=canvasapi_assignment)
271
+
272
+ def push_feedback(self, user_id, score: float, comments: str, attachments=None, keep_previous_best=True, clobber_feedback=False):
273
+ log.debug(f"Adding feedback for {user_id}")
274
+ if attachments is None:
275
+ attachments = []
276
+
277
+ # Get the previous score to check to see if we should reuse it
278
+ try:
279
+ submission = self.assignment.get_submission(user_id)
280
+ if keep_previous_best and score is not None and submission.score is not None and submission.score > score:
281
+ log.warning(f"Current score ({submission.score}) higher than new score ({score}). Going to use previous score.")
282
+ score = submission.score
283
+ except requests.exceptions.ConnectionError as e:
284
+ log.warning(f"No previous submission found for {user_id}")
285
+
286
+ # Update the assignment
287
+ # Note: the bulk_update will create a submission if none exists
288
+ try:
289
+ self.assignment.submissions_bulk_update(
290
+ grade_data={
291
+ 'submission[posted_grade]' : score
292
+ },
293
+ student_ids=[user_id]
294
+ )
295
+
296
+ submission = self.assignment.get_submission(user_id)
297
+ except requests.exceptions.ConnectionError as e:
298
+ log.error(e)
299
+ log.debug(f"Failed on user_id = {user_id})")
300
+ log.debug(f"username: {self.canvas_course.get_user(user_id)}")
301
+ return
302
+
303
+ # Push feedback to canvas
304
+ submission.edit(
305
+ submission={
306
+ 'posted_grade':score,
307
+ },
308
+ )
309
+
310
+ # If we should overwrite previous comments then remove all the previous submissions
311
+ if clobber_feedback:
312
+ log.debug("Clobbering...")
313
+ # todo: clobbering should probably be moved up or made into a different function for cleanliness.
314
+ for comment in submission.submission_comments:
315
+ comment_id = comment['id']
316
+
317
+ # Construct the URL to delete the comment
318
+ api_path = f"/api/v1/courses/{self.canvas_course.course.id}/assignments/{self.assignment.id}/submissions/{user_id}/comments/{comment_id}"
319
+ response = self.canvas_interface.canvas._Canvas__requester.request("DELETE", api_path)
320
+ if response.status_code == 200:
321
+ log.info(f"Deleted comment {comment_id}")
322
+ else:
323
+ log.warning(f"Failed to delete comment {comment_id}: {response.json()}")
324
+
325
+ def upload_buffer_as_file(buffer: bytes, name: str):
326
+ suffix = os.path.splitext(name)[1] # keep extension if needed
327
+ with tempfile.NamedTemporaryFile(mode="wb", delete=False, dir=".", prefix="feedback_", suffix=suffix) as tmp:
328
+ tmp.write(buffer)
329
+ tmp.flush()
330
+ os.fsync(tmp.fileno())
331
+ temp_path = tmp.name # str path
332
+
333
+ try:
334
+ submission.upload_comment(temp_path) # ✅ PathLike | str
335
+ finally:
336
+ os.remove(temp_path)
337
+
338
+ if len(comments) > 0:
339
+ upload_buffer_as_file(comments.encode('utf-8'), "feedback.txt")
340
+
341
+ for i, attachment_buffer in enumerate(attachments):
342
+ upload_buffer_as_file(attachment_buffer.read(), attachment_buffer.name)
343
+
344
+ def get_submissions(self, only_include_most_recent: bool = True, **kwargs) -> List[Submission]:
345
+ """
346
+ Gets submission objects (in this case Submission__Canvas objects) that have students and potentially attachments
347
+ :param only_include_most_recent: Include only the most recent submission
348
+ :param kwargs:
349
+ :return:
350
+ """
351
+
352
+ if "limit" in kwargs and kwargs["limit"] is not None:
353
+ limit = kwargs["limit"]
354
+ else:
355
+ limit = 1_000_000 # magically large number
356
+
357
+ test_only = kwargs.get("test", False)
358
+
359
+ submissions: List[Submission] = []
360
+
361
+ # Get all submissions and their history (which is necessary for attachments when students can resubmit)
362
+ for student_index, canvaspai_submission in enumerate(self.assignment.get_submissions(include='submission_history', **kwargs)):
363
+
364
+ # Get the student object for the submission
365
+ student = Student(
366
+ self.canvas_course.get_username(canvaspai_submission.user_id),
367
+ user_id=canvaspai_submission.user_id,
368
+ _inner=self.canvas_course.get_user(canvaspai_submission.user_id)
369
+ )
370
+
371
+ if test_only and not "Test Student" in student.name:
372
+ continue
373
+
374
+ log.debug(f"Checking submissions for {student.name} ({len(canvaspai_submission.submission_history)} submissions)")
375
+
376
+ # Walk through submissions in the reverse order, so we'll default to grabbing the most recent submission first
377
+ # This is important when we are going to be only including most recent
378
+ for student_submission_index, student_submission in (
379
+ reversed(list(enumerate(canvaspai_submission.submission_history)))):
380
+ log.debug(f"Submission: {student_submission['workflow_state']} " +
381
+ (f"{student_submission['score']:0.2f}" if student_submission['score'] is not None else "None"))
382
+
383
+ # Determine submission type based on content
384
+ has_attachments = student_submission.get("attachments") is not None and len(student_submission.get("attachments", [])) > 0
385
+ has_text_body = student_submission.get("body") is not None and student_submission.get("body").strip() != ""
386
+
387
+ if has_text_body:
388
+ # Text submission - create object-like structure from dict
389
+ log.debug(f"Detected text submission for {student.name}")
390
+ class SubmissionObject:
391
+ def __init__(self, data):
392
+ for key, value in data.items():
393
+ setattr(self, key, value)
394
+
395
+ submissions.append(
396
+ TextSubmission__Canvas(
397
+ student=student,
398
+ status=Submission.Status.from_string(student_submission["workflow_state"], student_submission['score']),
399
+ canvas_submission_data=SubmissionObject(student_submission),
400
+ submission_index=student_submission_index
401
+ )
402
+ )
403
+ elif has_attachments:
404
+ # File submission
405
+ log.debug(f"Detected file submission for {student.name}")
406
+ submissions.append(
407
+ FileSubmission__Canvas(
408
+ student=student,
409
+ status=Submission.Status.from_string(student_submission["workflow_state"], student_submission['score']),
410
+ attachments=student_submission["attachments"],
411
+ submission_index=student_submission_index
412
+ )
413
+ )
414
+ else:
415
+ # No submission content found
416
+ log.debug(f"No submission content found for {student.name}")
417
+ continue
418
+
419
+ # Check if we should only include the most recent
420
+ if only_include_most_recent: break
421
+
422
+ # Check if we are limiting how many students we are checking
423
+ if student_index >= (limit - 1): break
424
+
425
+ # Reverse the submissions again so we are preserving temporal order. This isn't necessary but makes me feel happy.
426
+ submissions = list(reversed(submissions))
427
+ return submissions
428
+
429
+ def get_students(self):
430
+ return self.canvas_course.get_students()
431
+
432
+
433
+ class CanvasQuiz(LMSWrapper):
434
+ """Canvas quiz interface for handling quiz submissions and responses"""
435
+
436
+ def __init__(self, *args, canvas_interface: CanvasInterface, canvasapi_course: CanvasCourse, canvasapi_quiz: canvasapi.quiz.Quiz, **kwargs):
437
+ self.canvas_interface = canvas_interface
438
+ self.canvas_course = canvasapi_course
439
+ self.quiz = canvasapi_quiz
440
+ super().__init__(_inner=canvasapi_quiz)
441
+
442
+ def get_quiz_submissions(self, **kwargs) -> List[QuizSubmission]:
443
+ """
444
+ Get all quiz submissions with student responses
445
+ :param kwargs: Additional parameters for filtering
446
+ :return: List of QuizSubmission objects
447
+ """
448
+ test_only = kwargs.get("test", False)
449
+ limit = kwargs.get("limit", 1_000_000)
450
+
451
+ quiz_submissions: List[QuizSubmission] = []
452
+
453
+ # Get all quiz submissions
454
+ for student_index, canvasapi_quiz_submission in enumerate(self.quiz.get_submissions(**kwargs)):
455
+
456
+ # Get the student object for the submission
457
+ try:
458
+ student = Student(
459
+ self.canvas_course.get_username(canvasapi_quiz_submission.user_id),
460
+ user_id=canvasapi_quiz_submission.user_id,
461
+ _inner=self.canvas_course.get_user(canvasapi_quiz_submission.user_id)
462
+ )
463
+ except Exception as e:
464
+ log.warning(f"Could not get student info for user_id {canvasapi_quiz_submission.user_id}: {e}")
465
+ continue
466
+
467
+ if test_only and "Test Student" not in student.name:
468
+ continue
469
+
470
+ log.debug(f"Processing quiz submission for {student.name}")
471
+
472
+ # Get detailed submission responses
473
+ try:
474
+ submission_questions = canvasapi_quiz_submission.get_submission_questions()
475
+
476
+ # Convert to our format: question_id -> response
477
+ student_responses = {}
478
+ quiz_questions = {}
479
+
480
+ for question in submission_questions:
481
+ question_id = question.id
482
+ student_responses[question_id] = {
483
+ 'answer': question.answer,
484
+ 'correct': getattr(question, 'correct', None),
485
+ 'points': getattr(question, 'points', 0),
486
+ 'question_type': getattr(question, 'question_type', 'unknown')
487
+ }
488
+
489
+ # Store question metadata
490
+ quiz_questions[question_id] = {
491
+ 'question_name': getattr(question, 'question_name', ''),
492
+ 'question_text': getattr(question, 'question_text', ''),
493
+ 'question_type': getattr(question, 'question_type', 'unknown'),
494
+ 'points_possible': getattr(question, 'points_possible', 0)
495
+ }
496
+
497
+ # Create QuizSubmission object
498
+ quiz_submission = QuizSubmission(
499
+ student=student,
500
+ status=Submission.Status.from_string(canvasapi_quiz_submission.workflow_state, canvasapi_quiz_submission.percentage_score),
501
+ quiz_submission_data=canvasapi_quiz_submission,
502
+ student_responses=student_responses,
503
+ quiz_questions=quiz_questions
504
+ )
505
+
506
+ quiz_submissions.append(quiz_submission)
507
+
508
+ except Exception as e:
509
+ log.error(f"Failed to get submission questions for {student.name}: {e}")
510
+ continue
511
+
512
+ # Check if we are limiting how many students we are checking
513
+ if student_index >= (limit - 1):
514
+ break
515
+
516
+ return quiz_submissions
517
+
518
+ def get_questions(self):
519
+ """Get all quiz questions"""
520
+ return self.quiz.get_questions()
521
+
522
+ def push_feedback(self, user_id, score: float, comments: str, **kwargs):
523
+ """
524
+ Push feedback for a quiz submission
525
+ Note: Quiz feedback mechanisms may be different from assignment feedback
526
+ """
527
+ # Quiz submissions typically don't support the same feedback mechanisms as assignments
528
+ # This is a placeholder for quiz-specific feedback handling
529
+ log.warning("Quiz feedback pushing not yet implemented")
530
+ pass
531
+
532
+
533
+ class CanvasHelpers:
534
+ @staticmethod
535
+ def get_closed_assignments(interface: CanvasCourse) -> List[canvasapi.assignment.Assignment]:
536
+ closed_assignments : List[canvasapi.assignment.Assignment] = []
537
+ for assignment in interface.get_assignments(
538
+ include=["all_dates"],
539
+ order_by="name"
540
+ ):
541
+ if not assignment.published:
542
+ continue
543
+ if assignment.lock_at is not None:
544
+ # Then it's the easy case because there's no overrides
545
+ if datetime.fromisoformat(assignment.lock_at) < datetime.now(timezone.utc):
546
+ # Then the assignment is past due
547
+ closed_assignments.append(assignment)
548
+ continue
549
+ elif assignment.all_dates is not None:
550
+
551
+ # First we need to figure out what the latest time this assignment could be available is
552
+ # todo: This could be done on a per-student basis
553
+ last_lock_datetime = None
554
+ for dates_dict in assignment.all_dates:
555
+ if dates_dict["lock_at"] is not None:
556
+ lock_datetime = datetime.fromisoformat(dates_dict["lock_at"])
557
+ if (last_lock_datetime is None) or (lock_datetime >= last_lock_datetime):
558
+ last_lock_datetime = lock_datetime
559
+
560
+ # If we have found a valid lock time, and it's in the past then we lock
561
+ if last_lock_datetime is not None and last_lock_datetime <= datetime.now(timezone.utc):
562
+ closed_assignments.append(assignment)
563
+ continue
564
+
565
+ else:
566
+ log.warning(f"Cannot find any lock dates for assignment {assignment.name}!")
567
+
568
+ return closed_assignments
569
+
570
+ @staticmethod
571
+ def get_unsubmitted_submissions(interface: CanvasCourse, assignment: canvasapi.assignment.Assignment) -> List[canvasapi.submission.Submission]:
572
+ submissions : List[canvasapi.submission.Submission] = list(filter(
573
+ lambda s: s.submitted_at is None and s.percentage_score is None and not s.excused,
574
+ assignment.get_submissions()
575
+ ))
576
+ return submissions
577
+
578
+ @classmethod
579
+ def clear_out_missing(cls, interface: CanvasCourse):
580
+ assignments = cls.get_closed_assignments(interface)
581
+ for assignment in assignments:
582
+ missing_submissions = cls.get_unsubmitted_submissions(interface, assignment)
583
+ if not missing_submissions:
584
+ continue
585
+ log.info(f"Assignment: ({assignment.quiz_id if hasattr(assignment, 'quiz_id') else assignment.id}) {assignment.name} {assignment.published}")
586
+ for submission in missing_submissions:
587
+ 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}")
588
+ submission.edit(submission={"late_policy_status" : "missing"})
589
+ log.info("")
590
+
591
+ @staticmethod
592
+ def deprecate_assignment(canvas_course: CanvasCourse, assignment_id) -> List[canvasapi.assignment.Assignment]:
593
+
594
+ log.debug(canvas_course.__dict__)
595
+
596
+ # for assignment in canvas_course.get_assignments():
597
+ # print(assignment)
598
+
599
+ canvas_assignment : CanvasAssignment = canvas_course.get_assignment(assignment_id=assignment_id)
600
+
601
+ canvas_assignment.assignment.edit(
602
+ assignment={
603
+ "name": f"{canvas_assignment.assignment.name} (deprecated)",
604
+ "due_at": f"{datetime.now(timezone.utc).isoformat()}",
605
+ "lock_at": f"{datetime.now(timezone.utc).isoformat()}"
606
+ }
607
+ )
608
+
609
+ @staticmethod
610
+ def mark_future_assignments_as_ungraded(canvas_course: CanvasCourse):
611
+
612
+ for assignment in canvas_course.get_assignments(
613
+ include=["all_dates"],
614
+ order_by="name"
615
+ ):
616
+ if assignment.unlock_at is not None:
617
+ if datetime.fromisoformat(assignment.unlock_at) > datetime.now(timezone.utc):
618
+ log.debug(assignment)
619
+ for submission in assignment.get_submissions():
620
+ submission.mark_unread()
621
+
622
+