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.
- QuizGenerator/README.md +5 -0
- QuizGenerator/__init__.py +27 -0
- QuizGenerator/__main__.py +7 -0
- QuizGenerator/canvas/__init__.py +13 -0
- QuizGenerator/canvas/canvas_interface.py +627 -0
- QuizGenerator/canvas/classes.py +235 -0
- QuizGenerator/constants.py +149 -0
- QuizGenerator/contentast.py +1955 -0
- QuizGenerator/generate.py +253 -0
- QuizGenerator/logging.yaml +55 -0
- QuizGenerator/misc.py +579 -0
- QuizGenerator/mixins.py +548 -0
- QuizGenerator/performance.py +202 -0
- QuizGenerator/premade_questions/__init__.py +0 -0
- QuizGenerator/premade_questions/basic.py +103 -0
- QuizGenerator/premade_questions/cst334/__init__.py +1 -0
- QuizGenerator/premade_questions/cst334/languages.py +391 -0
- QuizGenerator/premade_questions/cst334/math_questions.py +297 -0
- QuizGenerator/premade_questions/cst334/memory_questions.py +1400 -0
- QuizGenerator/premade_questions/cst334/ostep13_vsfs.py +572 -0
- QuizGenerator/premade_questions/cst334/persistence_questions.py +451 -0
- QuizGenerator/premade_questions/cst334/process.py +648 -0
- QuizGenerator/premade_questions/cst463/__init__.py +0 -0
- QuizGenerator/premade_questions/cst463/gradient_descent/__init__.py +3 -0
- QuizGenerator/premade_questions/cst463/gradient_descent/gradient_calculation.py +369 -0
- QuizGenerator/premade_questions/cst463/gradient_descent/gradient_descent_questions.py +305 -0
- QuizGenerator/premade_questions/cst463/gradient_descent/loss_calculations.py +650 -0
- QuizGenerator/premade_questions/cst463/gradient_descent/misc.py +73 -0
- QuizGenerator/premade_questions/cst463/math_and_data/__init__.py +2 -0
- QuizGenerator/premade_questions/cst463/math_and_data/matrix_questions.py +631 -0
- QuizGenerator/premade_questions/cst463/math_and_data/vector_questions.py +534 -0
- QuizGenerator/premade_questions/cst463/models/__init__.py +0 -0
- QuizGenerator/premade_questions/cst463/models/attention.py +192 -0
- QuizGenerator/premade_questions/cst463/models/cnns.py +186 -0
- QuizGenerator/premade_questions/cst463/models/matrices.py +24 -0
- QuizGenerator/premade_questions/cst463/models/rnns.py +202 -0
- QuizGenerator/premade_questions/cst463/models/text.py +203 -0
- QuizGenerator/premade_questions/cst463/models/weight_counting.py +227 -0
- QuizGenerator/premade_questions/cst463/neural-network-basics/__init__.py +6 -0
- QuizGenerator/premade_questions/cst463/neural-network-basics/neural_network_questions.py +1314 -0
- QuizGenerator/premade_questions/cst463/tensorflow-intro/__init__.py +6 -0
- QuizGenerator/premade_questions/cst463/tensorflow-intro/tensorflow_questions.py +936 -0
- QuizGenerator/qrcode_generator.py +293 -0
- QuizGenerator/question.py +715 -0
- QuizGenerator/quiz.py +467 -0
- QuizGenerator/regenerate.py +472 -0
- QuizGenerator/typst_utils.py +113 -0
- quizgenerator-0.4.2.dist-info/METADATA +265 -0
- quizgenerator-0.4.2.dist-info/RECORD +52 -0
- quizgenerator-0.4.2.dist-info/WHEEL +4 -0
- quizgenerator-0.4.2.dist-info/entry_points.txt +3 -0
- quizgenerator-0.4.2.dist-info/licenses/LICENSE +674 -0
QuizGenerator/README.md
ADDED
|
@@ -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,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
|
+
|