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
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""
|
|
2
|
+
QR Code generation module for quiz questions.
|
|
3
|
+
|
|
4
|
+
This module generates QR codes containing question metadata (question number,
|
|
5
|
+
points value, etc.) that can be embedded in PDF output for scanning and
|
|
6
|
+
automated grading.
|
|
7
|
+
|
|
8
|
+
The QR codes include encrypted data that allows regenerating question answers
|
|
9
|
+
without storing separate files, enabling efficient grading of randomized exams.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import tempfile
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
import base64
|
|
17
|
+
from io import BytesIO
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Optional, Dict, Any
|
|
20
|
+
|
|
21
|
+
import segno
|
|
22
|
+
from cryptography.fernet import Fernet
|
|
23
|
+
|
|
24
|
+
log = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class QuestionQRCode:
|
|
28
|
+
"""
|
|
29
|
+
Generator for question metadata QR codes.
|
|
30
|
+
|
|
31
|
+
QR codes encode question information in JSON format for easy parsing
|
|
32
|
+
after scanning. They use high error correction (30% recovery) to ensure
|
|
33
|
+
reliability when printed and scanned.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
# QR code size in cm for LaTeX output (suitable for 200 DPI scanning)
|
|
37
|
+
DEFAULT_SIZE_CM = 1.5 # Compact size suitable for ~60 char encoded data
|
|
38
|
+
|
|
39
|
+
# Error correction level: M = 15% recovery (balanced for compact encoded data)
|
|
40
|
+
ERROR_CORRECTION = 'M'
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def get_encryption_key(cls) -> bytes:
|
|
44
|
+
"""
|
|
45
|
+
Get encryption key from environment or generate new one.
|
|
46
|
+
|
|
47
|
+
The key is loaded from QUIZ_ENCRYPTION_KEY environment variable.
|
|
48
|
+
If not set, generates a new key (for development only).
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
bytes: Fernet encryption key
|
|
52
|
+
|
|
53
|
+
Note:
|
|
54
|
+
In production, always set QUIZ_ENCRYPTION_KEY environment variable!
|
|
55
|
+
Generate a key once with: Fernet.generate_key()
|
|
56
|
+
"""
|
|
57
|
+
key_str = os.environ.get('QUIZ_ENCRYPTION_KEY')
|
|
58
|
+
|
|
59
|
+
if key_str is None:
|
|
60
|
+
log.warning(
|
|
61
|
+
"QUIZ_ENCRYPTION_KEY not set! Generating temporary key. "
|
|
62
|
+
"Set this environment variable for production use!"
|
|
63
|
+
)
|
|
64
|
+
# Generate temporary key for development
|
|
65
|
+
return Fernet.generate_key()
|
|
66
|
+
|
|
67
|
+
# Key should be stored as base64 string in env
|
|
68
|
+
return key_str.encode()
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def encrypt_question_data(cls, question_type: str, seed: int, version: str,
|
|
72
|
+
config: Optional[Dict[str, Any]] = None,
|
|
73
|
+
key: Optional[bytes] = None) -> str:
|
|
74
|
+
"""
|
|
75
|
+
Encode question regeneration data with optional simple obfuscation.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
question_type: Class name of the question (e.g., "VectorDotProduct")
|
|
79
|
+
seed: Random seed used to generate this specific question
|
|
80
|
+
version: Question class version (e.g., "1.0")
|
|
81
|
+
config: Optional dictionary of configuration parameters
|
|
82
|
+
key: Encryption key (uses environment key if None)
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
str: Base64-encoded (optionally XOR-obfuscated) data
|
|
86
|
+
|
|
87
|
+
Example:
|
|
88
|
+
>>> encrypted = QuestionQRCode.encrypt_question_data("VectorDot", 12345, "1.0")
|
|
89
|
+
>>> print(encrypted)
|
|
90
|
+
'VmVjdG9yRG90OjEyMzQ1OjEuMA=='
|
|
91
|
+
"""
|
|
92
|
+
# Create compact data string, including config if provided
|
|
93
|
+
if config:
|
|
94
|
+
# Serialize config as JSON and append to data string
|
|
95
|
+
config_json = json.dumps(config, separators=(',', ':'))
|
|
96
|
+
data_str = f"{question_type}:{seed}:{version}:{config_json}"
|
|
97
|
+
else:
|
|
98
|
+
data_str = f"{question_type}:{seed}:{version}"
|
|
99
|
+
data_bytes = data_str.encode('utf-8')
|
|
100
|
+
|
|
101
|
+
# Simple XOR obfuscation if key is provided (optional, for basic protection)
|
|
102
|
+
if key is None:
|
|
103
|
+
key = cls.get_encryption_key()
|
|
104
|
+
|
|
105
|
+
if key:
|
|
106
|
+
# Use first 16 bytes of key for simple XOR obfuscation
|
|
107
|
+
key_bytes = key[:16] if isinstance(key, bytes) else key.encode()[:16]
|
|
108
|
+
# XOR each byte with repeating key pattern
|
|
109
|
+
obfuscated = bytes(b ^ key_bytes[i % len(key_bytes)] for i, b in enumerate(data_bytes))
|
|
110
|
+
else:
|
|
111
|
+
obfuscated = data_bytes
|
|
112
|
+
|
|
113
|
+
# Base64 encode for compact representation
|
|
114
|
+
encoded = base64.urlsafe_b64encode(obfuscated).decode('utf-8')
|
|
115
|
+
|
|
116
|
+
log.debug(f"Encoded question data: {question_type} seed={seed} version={version} ({len(encoded)} chars)")
|
|
117
|
+
|
|
118
|
+
return encoded
|
|
119
|
+
|
|
120
|
+
@classmethod
|
|
121
|
+
def decrypt_question_data(cls, encrypted_data: str,
|
|
122
|
+
key: Optional[bytes] = None) -> Dict[str, Any]:
|
|
123
|
+
"""
|
|
124
|
+
Decode question regeneration data from QR code.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
encrypted_data: Base64-encoded (optionally XOR-obfuscated) string from QR code
|
|
128
|
+
key: Encryption key (uses environment key if None)
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
dict: {"question_type": str, "seed": int, "version": str, "config": dict (optional)}
|
|
132
|
+
|
|
133
|
+
Raises:
|
|
134
|
+
ValueError: If decoding fails or data is malformed
|
|
135
|
+
|
|
136
|
+
Example:
|
|
137
|
+
>>> data = QuestionQRCode.decrypt_question_data("VmVjdG9yRG90OjEyMzQ1OjEuMA==")
|
|
138
|
+
>>> print(data)
|
|
139
|
+
{"question_type": "VectorDot", "seed": 12345, "version": "1.0"}
|
|
140
|
+
"""
|
|
141
|
+
if key is None:
|
|
142
|
+
key = cls.get_encryption_key()
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
# Decode from base64
|
|
146
|
+
obfuscated = base64.urlsafe_b64decode(encrypted_data.encode())
|
|
147
|
+
|
|
148
|
+
# Reverse XOR obfuscation if key is provided
|
|
149
|
+
if key:
|
|
150
|
+
key_bytes = key[:16] if isinstance(key, bytes) else key.encode()[:16]
|
|
151
|
+
data_bytes = bytes(b ^ key_bytes[i % len(key_bytes)] for i, b in enumerate(obfuscated))
|
|
152
|
+
else:
|
|
153
|
+
data_bytes = obfuscated
|
|
154
|
+
|
|
155
|
+
data_str = data_bytes.decode('utf-8')
|
|
156
|
+
|
|
157
|
+
# Parse data string - can be 3 or 4 parts (4th is optional config)
|
|
158
|
+
parts = data_str.split(':', 3) # Split into max 4 parts
|
|
159
|
+
if len(parts) < 3:
|
|
160
|
+
raise ValueError(f"Invalid encoded data format: expected at least 3 parts, got {len(parts)}")
|
|
161
|
+
|
|
162
|
+
question_type = parts[0]
|
|
163
|
+
seed_str = parts[1]
|
|
164
|
+
version = parts[2]
|
|
165
|
+
|
|
166
|
+
result = {
|
|
167
|
+
"question_type": question_type,
|
|
168
|
+
"seed": int(seed_str),
|
|
169
|
+
"version": version
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
# Parse config JSON if present
|
|
173
|
+
if len(parts) == 4:
|
|
174
|
+
try:
|
|
175
|
+
result["config"] = json.loads(parts[3])
|
|
176
|
+
except json.JSONDecodeError as e:
|
|
177
|
+
log.warning(f"Failed to parse config JSON: {e}")
|
|
178
|
+
# Continue without config rather than failing
|
|
179
|
+
|
|
180
|
+
return result
|
|
181
|
+
|
|
182
|
+
except Exception as e:
|
|
183
|
+
log.error(f"Failed to decode question data: {e}")
|
|
184
|
+
raise ValueError(f"Failed to decode QR code data: {e}")
|
|
185
|
+
|
|
186
|
+
@classmethod
|
|
187
|
+
def generate_qr_data(cls, question_number: int, points_value: float, **extra_data) -> str:
|
|
188
|
+
"""
|
|
189
|
+
Generate JSON string containing question metadata.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
question_number: Sequential question number in the quiz
|
|
193
|
+
points_value: Point value of the question
|
|
194
|
+
**extra_data: Additional metadata to include
|
|
195
|
+
- question_type (str): Question class name for regeneration
|
|
196
|
+
- seed (int): Random seed used for this question
|
|
197
|
+
- version (str): Question class version
|
|
198
|
+
- config (dict): Question-specific configuration parameters
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
JSON string with question metadata
|
|
202
|
+
|
|
203
|
+
Example:
|
|
204
|
+
>>> QuestionQRCode.generate_qr_data(1, 5.0)
|
|
205
|
+
'{"q": 1, "pts": 5.0}'
|
|
206
|
+
|
|
207
|
+
>>> QuestionQRCode.generate_qr_data(
|
|
208
|
+
... 2, 10,
|
|
209
|
+
... question_type="VectorDot",
|
|
210
|
+
... seed=12345,
|
|
211
|
+
... version="1.0",
|
|
212
|
+
... config={"max_value": 100}
|
|
213
|
+
... )
|
|
214
|
+
'{"q": 2, "pts": 10, "s": "gAAAAAB..."}'
|
|
215
|
+
"""
|
|
216
|
+
data = {
|
|
217
|
+
"q": question_number,
|
|
218
|
+
"pts": points_value
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
# If question regeneration data provided, encrypt it
|
|
222
|
+
if all(k in extra_data for k in ['question_type', 'seed', 'version']):
|
|
223
|
+
# Include config in encrypted data if present
|
|
224
|
+
config = extra_data.get('config', {})
|
|
225
|
+
encrypted = cls.encrypt_question_data(
|
|
226
|
+
extra_data['question_type'],
|
|
227
|
+
extra_data['seed'],
|
|
228
|
+
extra_data['version'],
|
|
229
|
+
config=config
|
|
230
|
+
)
|
|
231
|
+
data['s'] = encrypted
|
|
232
|
+
|
|
233
|
+
# Remove the unencrypted data
|
|
234
|
+
extra_data = {k: v for k, v in extra_data.items()
|
|
235
|
+
if k not in ['question_type', 'seed', 'version', 'config']}
|
|
236
|
+
|
|
237
|
+
# Add any remaining extra metadata
|
|
238
|
+
data.update(extra_data)
|
|
239
|
+
|
|
240
|
+
return json.dumps(data, separators=(',', ':'))
|
|
241
|
+
|
|
242
|
+
@classmethod
|
|
243
|
+
def generate_qr_pdf(cls, question_number: int, points_value: float,
|
|
244
|
+
scale: int = 10, **extra_data) -> str:
|
|
245
|
+
"""
|
|
246
|
+
Generate QR code and save as PNG file, returning the file path.
|
|
247
|
+
|
|
248
|
+
This is used for LaTeX inclusion via \\includegraphics.
|
|
249
|
+
The file is saved to a temporary location that LaTeX can access.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
question_number: Sequential question number
|
|
253
|
+
points_value: Point value of the question
|
|
254
|
+
scale: Scale factor for PNG generation (higher = larger file, better quality)
|
|
255
|
+
**extra_data: Additional metadata
|
|
256
|
+
|
|
257
|
+
Returns:
|
|
258
|
+
Path to generated PNG file
|
|
259
|
+
"""
|
|
260
|
+
qr_data = cls.generate_qr_data(question_number, points_value, **extra_data)
|
|
261
|
+
|
|
262
|
+
# Generate QR code with high error correction
|
|
263
|
+
qr = segno.make(qr_data, error=cls.ERROR_CORRECTION)
|
|
264
|
+
|
|
265
|
+
# Create temporary file for the PNG
|
|
266
|
+
# We use a predictable name based on question number so LaTeX can find it
|
|
267
|
+
temp_dir = Path(tempfile.gettempdir()) / "quiz_qrcodes"
|
|
268
|
+
temp_dir.mkdir(exist_ok=True)
|
|
269
|
+
|
|
270
|
+
qr_path = temp_dir / f"qr_q{question_number}.pdf"
|
|
271
|
+
|
|
272
|
+
# Save as PNG with appropriate scale
|
|
273
|
+
qr.save(str(qr_path), scale=scale, border=0)
|
|
274
|
+
|
|
275
|
+
log.debug(f"Generated QR code for question {question_number} at {qr_path}")
|
|
276
|
+
|
|
277
|
+
return str(qr_path)
|
|
278
|
+
|
|
279
|
+
@classmethod
|
|
280
|
+
def cleanup_temp_files(cls):
|
|
281
|
+
"""
|
|
282
|
+
Clean up temporary QR code files.
|
|
283
|
+
|
|
284
|
+
Call this after PDF generation is complete to remove temporary files.
|
|
285
|
+
"""
|
|
286
|
+
temp_dir = Path(tempfile.gettempdir()) / "quiz_qrcodes"
|
|
287
|
+
if temp_dir.exists():
|
|
288
|
+
for qr_file in temp_dir.glob("qr_q*.png"):
|
|
289
|
+
try:
|
|
290
|
+
qr_file.unlink()
|
|
291
|
+
log.debug(f"Cleaned up QR code file: {qr_file}")
|
|
292
|
+
except Exception as e:
|
|
293
|
+
log.warning(f"Failed to clean up {qr_file}: {e}")
|