canvaslms 5.4__py3-none-any.whl → 5.5__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.
- canvaslms/cli/quizzes.nw +699 -31
- canvaslms/cli/quizzes.py +593 -25
- {canvaslms-5.4.dist-info → canvaslms-5.5.dist-info}/METADATA +1 -1
- {canvaslms-5.4.dist-info → canvaslms-5.5.dist-info}/RECORD +7 -7
- {canvaslms-5.4.dist-info → canvaslms-5.5.dist-info}/WHEEL +0 -0
- {canvaslms-5.4.dist-info → canvaslms-5.5.dist-info}/entry_points.txt +0 -0
- {canvaslms-5.4.dist-info → canvaslms-5.5.dist-info}/licenses/LICENSE +0 -0
canvaslms/cli/quizzes.nw
CHANGED
|
@@ -326,12 +326,14 @@ subcommands:
|
|
|
326
326
|
<<[[quizzes.py]]>>=
|
|
327
327
|
import argparse
|
|
328
328
|
import csv
|
|
329
|
+
import difflib
|
|
329
330
|
import json
|
|
330
331
|
import os
|
|
331
332
|
import re
|
|
332
333
|
import statistics
|
|
333
334
|
import sys
|
|
334
335
|
import time
|
|
336
|
+
import yaml
|
|
335
337
|
from collections import defaultdict, Counter
|
|
336
338
|
from typing import Dict, List, Any
|
|
337
339
|
|
|
@@ -2977,23 +2979,203 @@ NEW_QUIZ_RESULT_VIEW_SCHEMA = {
|
|
|
2977
2979
|
@
|
|
2978
2980
|
|
|
2979
2981
|
|
|
2982
|
+
\subsection{File formats for quiz editing}
|
|
2983
|
+
\label{sec:quiz-file-formats}
|
|
2984
|
+
|
|
2985
|
+
The [[quizzes edit]] command supports three file formats, auto-detected from
|
|
2986
|
+
the file extension and verified against the content:
|
|
2987
|
+
\begin{description}
|
|
2988
|
+
\item[JSON format] ([[.json]]) Full quiz structure with [[settings]] and
|
|
2989
|
+
optional [[items]]. This is the same format used by [[quizzes export]] and
|
|
2990
|
+
[[quizzes create]], enabling a round-trip workflow.
|
|
2991
|
+
\item[YAML format] ([[.yaml]], [[.yml]]) Same structure as JSON but in YAML
|
|
2992
|
+
syntax. More readable for hand-editing.
|
|
2993
|
+
\item[Front matter format] ([[.md]]) YAML front matter with Markdown body for
|
|
2994
|
+
instructions. This is the default interactive editing format, focused on
|
|
2995
|
+
editing settings and instructions without touching questions.
|
|
2996
|
+
\end{description}
|
|
2997
|
+
|
|
2998
|
+
The format is detected by file extension and verified by checking the first
|
|
2999
|
+
characters of the file:
|
|
3000
|
+
\begin{itemize}
|
|
3001
|
+
\item JSON files start with [[{]]
|
|
3002
|
+
\item YAML full format starts with [[quiz_type:]] or [[settings:]]
|
|
3003
|
+
\item Front matter format starts with [[---]]
|
|
3004
|
+
\end{itemize}
|
|
3005
|
+
|
|
3006
|
+
<<functions>>=
|
|
3007
|
+
def detect_quiz_file_format(filepath):
|
|
3008
|
+
"""Detect quiz file format from extension and content
|
|
3009
|
+
|
|
3010
|
+
Args:
|
|
3011
|
+
filepath: Path to the file
|
|
3012
|
+
|
|
3013
|
+
Returns:
|
|
3014
|
+
'json', 'yaml', or 'frontmatter'
|
|
3015
|
+
|
|
3016
|
+
Raises:
|
|
3017
|
+
FileNotFoundError: If file doesn't exist
|
|
3018
|
+
ValueError: If format cannot be determined or extension mismatches content
|
|
3019
|
+
"""
|
|
3020
|
+
# Check extension
|
|
3021
|
+
ext = os.path.splitext(filepath)[1].lower()
|
|
3022
|
+
|
|
3023
|
+
# Read file content
|
|
3024
|
+
with open(filepath, 'r', encoding='utf-8') as f:
|
|
3025
|
+
file_content = f.read()
|
|
3026
|
+
|
|
3027
|
+
content_stripped = file_content.lstrip()
|
|
3028
|
+
|
|
3029
|
+
# Determine expected format from extension
|
|
3030
|
+
if ext == '.json':
|
|
3031
|
+
expected = 'json'
|
|
3032
|
+
elif ext in ('.yaml', '.yml'):
|
|
3033
|
+
expected = 'yaml'
|
|
3034
|
+
elif ext == '.md':
|
|
3035
|
+
expected = 'frontmatter'
|
|
3036
|
+
else:
|
|
3037
|
+
expected = None # Auto-detect
|
|
3038
|
+
|
|
3039
|
+
# Detect actual format from content
|
|
3040
|
+
if content_stripped.startswith('{'):
|
|
3041
|
+
actual = 'json'
|
|
3042
|
+
elif content_stripped.startswith('---'):
|
|
3043
|
+
actual = 'frontmatter'
|
|
3044
|
+
elif (content_stripped.startswith('quiz_type:') or
|
|
3045
|
+
content_stripped.startswith('settings:')):
|
|
3046
|
+
actual = 'yaml'
|
|
3047
|
+
else:
|
|
3048
|
+
# For YAML, try parsing to see if it's valid
|
|
3049
|
+
try:
|
|
3050
|
+
data = yaml.safe_load(content_stripped)
|
|
3051
|
+
if isinstance(data, dict) and ('settings' in data or 'title' in data):
|
|
3052
|
+
actual = 'yaml'
|
|
3053
|
+
else:
|
|
3054
|
+
raise ValueError(f"Cannot determine format of {filepath}")
|
|
3055
|
+
except yaml.YAMLError:
|
|
3056
|
+
raise ValueError(f"Cannot determine format of {filepath}")
|
|
3057
|
+
|
|
3058
|
+
# Verify match if extension specified a format
|
|
3059
|
+
if expected and expected != actual:
|
|
3060
|
+
raise ValueError(
|
|
3061
|
+
f"File extension suggests {expected} but content looks like {actual}"
|
|
3062
|
+
)
|
|
3063
|
+
|
|
3064
|
+
return actual, file_content
|
|
3065
|
+
@
|
|
3066
|
+
|
|
3067
|
+
The [[read_quiz_from_file]] function reads quiz data from any supported format
|
|
3068
|
+
and returns a unified structure that can be used for updating the quiz.
|
|
3069
|
+
|
|
3070
|
+
<<functions>>=
|
|
3071
|
+
def read_quiz_from_file(filepath):
|
|
3072
|
+
"""Read quiz data from JSON, YAML, or front matter file
|
|
3073
|
+
|
|
3074
|
+
Args:
|
|
3075
|
+
filepath: Path to the quiz file
|
|
3076
|
+
|
|
3077
|
+
Returns:
|
|
3078
|
+
Dictionary with:
|
|
3079
|
+
'format': 'json'|'yaml'|'frontmatter'
|
|
3080
|
+
'settings': dict of quiz settings
|
|
3081
|
+
'instructions': str or None (body for frontmatter format)
|
|
3082
|
+
'items': list or None (questions, for json/yaml)
|
|
3083
|
+
'quiz_type': 'new'|'classic' or None
|
|
3084
|
+
|
|
3085
|
+
Raises:
|
|
3086
|
+
FileNotFoundError: If file doesn't exist
|
|
3087
|
+
ValueError: If file format is invalid
|
|
3088
|
+
"""
|
|
3089
|
+
format_type, file_content = detect_quiz_file_format(filepath)
|
|
3090
|
+
|
|
3091
|
+
if format_type == 'json':
|
|
3092
|
+
try:
|
|
3093
|
+
data = json.loads(file_content)
|
|
3094
|
+
except json.JSONDecodeError as e:
|
|
3095
|
+
raise ValueError(f"Invalid JSON: {e}")
|
|
3096
|
+
return _parse_quiz_full_format(data, 'json')
|
|
3097
|
+
|
|
3098
|
+
elif format_type == 'yaml':
|
|
3099
|
+
try:
|
|
3100
|
+
data = yaml.safe_load(file_content)
|
|
3101
|
+
except yaml.YAMLError as e:
|
|
3102
|
+
raise ValueError(f"Invalid YAML: {e}")
|
|
3103
|
+
return _parse_quiz_full_format(data, 'yaml')
|
|
3104
|
+
|
|
3105
|
+
else: # frontmatter
|
|
3106
|
+
attributes, body = content.parse_yaml_front_matter(file_content)
|
|
3107
|
+
return {
|
|
3108
|
+
'format': 'frontmatter',
|
|
3109
|
+
'settings': attributes,
|
|
3110
|
+
'instructions': body.strip() if body else None,
|
|
3111
|
+
'items': None,
|
|
3112
|
+
'quiz_type': None
|
|
3113
|
+
}
|
|
3114
|
+
|
|
3115
|
+
|
|
3116
|
+
def _parse_quiz_full_format(data, format_type):
|
|
3117
|
+
"""Parse full format (JSON or YAML) quiz data
|
|
3118
|
+
|
|
3119
|
+
Handles both the full format with 'settings' key and the simple format
|
|
3120
|
+
where the entire dict is settings.
|
|
3121
|
+
"""
|
|
3122
|
+
if not isinstance(data, dict):
|
|
3123
|
+
raise ValueError(f"Expected a dictionary, got {type(data).__name__}")
|
|
3124
|
+
|
|
3125
|
+
if 'settings' in data:
|
|
3126
|
+
settings = data['settings'].copy()
|
|
3127
|
+
else:
|
|
3128
|
+
# Simple format: entire dict is settings (excluding metadata keys)
|
|
3129
|
+
settings = {k: v for k, v in data.items()
|
|
3130
|
+
if k not in ('quiz_type', 'items', 'questions')}
|
|
3131
|
+
|
|
3132
|
+
# Extract instructions from settings if present
|
|
3133
|
+
instructions = settings.get('instructions')
|
|
3134
|
+
|
|
3135
|
+
return {
|
|
3136
|
+
'format': format_type,
|
|
3137
|
+
'settings': settings,
|
|
3138
|
+
'instructions': instructions,
|
|
3139
|
+
'items': data.get('items') or data.get('questions'),
|
|
3140
|
+
'quiz_type': data.get('quiz_type')
|
|
3141
|
+
}
|
|
3142
|
+
@
|
|
3143
|
+
|
|
3144
|
+
|
|
2980
3145
|
\subsection{Command-line interface}
|
|
2981
3146
|
|
|
2982
3147
|
The edit command takes course and quiz selection options. The [[-f]] option
|
|
2983
|
-
|
|
2984
|
-
|
|
3148
|
+
supports multiple file formats (JSON, YAML, Markdown with front matter), which
|
|
3149
|
+
are auto-detected from the file extension. The [[--full-json]] option enables
|
|
3150
|
+
interactive editing in full JSON format (same as [[quizzes export]]).
|
|
2985
3151
|
|
|
2986
3152
|
<<add quizzes edit command to subp>>=
|
|
2987
3153
|
edit_parser = subp.add_parser("edit",
|
|
2988
3154
|
help="Edit quiz settings and instructions",
|
|
2989
3155
|
description="""Edit an existing quiz's settings and instructions.
|
|
2990
3156
|
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
3157
|
+
INTERACTIVE MODE (default):
|
|
3158
|
+
Opens your editor with YAML front matter (settings) and Markdown body
|
|
3159
|
+
(instructions). After editing, shows a preview and asks whether to
|
|
3160
|
+
accept, edit further, or discard the changes.
|
|
3161
|
+
|
|
3162
|
+
Use --full-json to edit as full JSON (same format as 'quizzes export -I').
|
|
3163
|
+
This allows editing all quiz_settings including multiple_attempts and
|
|
3164
|
+
result_view_settings.
|
|
2994
3165
|
|
|
2995
|
-
|
|
2996
|
-
|
|
3166
|
+
FILE MODE (-f):
|
|
3167
|
+
Reads content from a file. Format is auto-detected from extension:
|
|
3168
|
+
.json - Full JSON format (settings + optional items)
|
|
3169
|
+
.yaml/.yml - Full YAML format (same structure as JSON)
|
|
3170
|
+
.md - YAML front matter + Markdown body
|
|
3171
|
+
|
|
3172
|
+
The JSON/YAML format is the same as 'quizzes export' output, enabling
|
|
3173
|
+
a round-trip workflow: export, modify, edit.
|
|
3174
|
+
|
|
3175
|
+
ITEM HANDLING:
|
|
3176
|
+
By default, items/questions in the file are ignored to protect student
|
|
3177
|
+
submissions. Use --replace-items to replace all questions (with confirmation
|
|
3178
|
+
if submissions exist).
|
|
2997
3179
|
|
|
2998
3180
|
The quiz type (New or Classic) is auto-detected.""")
|
|
2999
3181
|
|
|
@@ -3007,13 +3189,24 @@ except argparse.ArgumentError:
|
|
|
3007
3189
|
add_quiz_option(edit_parser, required=True)
|
|
3008
3190
|
|
|
3009
3191
|
edit_parser.add_argument("-f", "--file",
|
|
3010
|
-
help="Read content from
|
|
3192
|
+
help="Read content from file (format auto-detected: .json, .yaml, .yml, .md)",
|
|
3011
3193
|
type=str,
|
|
3012
3194
|
required=False)
|
|
3013
3195
|
|
|
3014
3196
|
edit_parser.add_argument("--html",
|
|
3015
3197
|
action="store_true",
|
|
3016
3198
|
help="Edit raw HTML instead of converting to Markdown")
|
|
3199
|
+
|
|
3200
|
+
edit_parser.add_argument("--full-json",
|
|
3201
|
+
action="store_true",
|
|
3202
|
+
help="Interactive mode: edit as full JSON instead of YAML+Markdown. "
|
|
3203
|
+
"Allows editing all quiz_settings including multiple_attempts.")
|
|
3204
|
+
|
|
3205
|
+
edit_parser.add_argument("--replace-items",
|
|
3206
|
+
action="store_true",
|
|
3207
|
+
help="Replace existing questions with items from file. "
|
|
3208
|
+
"Default: ignore items to preserve student attempts. "
|
|
3209
|
+
"Will prompt for confirmation if quiz has submissions.")
|
|
3017
3210
|
@
|
|
3018
3211
|
|
|
3019
3212
|
|
|
@@ -3035,31 +3228,70 @@ def edit_command(config, canvas, args):
|
|
|
3035
3228
|
<<edit quiz interactively>>
|
|
3036
3229
|
@
|
|
3037
3230
|
|
|
3038
|
-
In file mode, we
|
|
3039
|
-
|
|
3040
|
-
|
|
3231
|
+
In file mode, we use [[read_quiz_from_file]] to detect the format and parse
|
|
3232
|
+
the content appropriately. The function returns a unified structure that
|
|
3233
|
+
works for all three formats (JSON, YAML, or Markdown with front matter).
|
|
3234
|
+
|
|
3235
|
+
For JSON and YAML files, the structure may contain [[items]] (questions).
|
|
3236
|
+
By default we ignore items to protect student submissions---use
|
|
3237
|
+
[[--replace-items]] to replace them (after confirmation if submissions exist).
|
|
3041
3238
|
|
|
3042
3239
|
<<edit quiz from file>>=
|
|
3043
3240
|
try:
|
|
3044
|
-
|
|
3241
|
+
quiz_data = read_quiz_from_file(args.file)
|
|
3045
3242
|
except FileNotFoundError:
|
|
3046
3243
|
canvaslms.cli.err(1, f"File not found: {args.file}")
|
|
3047
3244
|
except ValueError as e:
|
|
3048
3245
|
canvaslms.cli.err(1, f"Invalid file format: {e}")
|
|
3049
3246
|
|
|
3050
|
-
|
|
3051
|
-
if
|
|
3247
|
+
<<identify target quiz from file data>>
|
|
3248
|
+
<<handle item replacement if requested>>
|
|
3249
|
+
<<apply settings from file>>
|
|
3250
|
+
@
|
|
3251
|
+
|
|
3252
|
+
We extract [[id]] from either the top level or the settings:
|
|
3253
|
+
|
|
3254
|
+
<<identify target quiz from file data>>=
|
|
3255
|
+
settings = quiz_data['settings']
|
|
3256
|
+
quiz_id = settings.get('id')
|
|
3257
|
+
|
|
3258
|
+
if quiz_id:
|
|
3052
3259
|
target_quiz = None
|
|
3053
3260
|
for quiz in quiz_list:
|
|
3054
|
-
if str(quiz.id) == str(
|
|
3261
|
+
if str(quiz.id) == str(quiz_id):
|
|
3055
3262
|
target_quiz = quiz
|
|
3056
3263
|
break
|
|
3057
3264
|
if not target_quiz:
|
|
3058
|
-
canvaslms.cli.err(1, f"Quiz with ID {
|
|
3265
|
+
canvaslms.cli.err(1, f"Quiz with ID {quiz_id} not found")
|
|
3059
3266
|
quiz_list = [target_quiz]
|
|
3267
|
+
@
|
|
3268
|
+
|
|
3269
|
+
If [[--replace-items]] is specified and the file contains items, we replace
|
|
3270
|
+
the quiz questions. But first we check for student submissions and ask for
|
|
3271
|
+
confirmation if any exist.
|
|
3272
|
+
|
|
3273
|
+
<<handle item replacement if requested>>=
|
|
3274
|
+
items = quiz_data.get('items')
|
|
3275
|
+
if args.replace_items and items:
|
|
3276
|
+
for quiz in quiz_list:
|
|
3277
|
+
<<check submissions and replace items>>
|
|
3278
|
+
elif items:
|
|
3279
|
+
print(f"Note: Ignoring {len(items)} items in file (use --replace-items to update)",
|
|
3280
|
+
file=sys.stderr)
|
|
3281
|
+
@
|
|
3282
|
+
|
|
3283
|
+
The settings are applied using [[apply_quiz_edit]]. For frontmatter format,
|
|
3284
|
+
the body (instructions) is separate; for JSON/YAML it's in settings.
|
|
3285
|
+
|
|
3286
|
+
<<apply settings from file>>=
|
|
3287
|
+
body = quiz_data.get('instructions') or ''
|
|
3060
3288
|
|
|
3061
3289
|
for quiz in quiz_list:
|
|
3062
|
-
|
|
3290
|
+
# For JSON/YAML, instructions may be in settings; extract it for body
|
|
3291
|
+
if quiz_data['format'] in ('json', 'yaml'):
|
|
3292
|
+
body = settings.get('instructions', '') or ''
|
|
3293
|
+
|
|
3294
|
+
success = apply_quiz_edit(quiz, settings, body, requester, args.html)
|
|
3063
3295
|
if success:
|
|
3064
3296
|
print(f"Updated quiz: {quiz.title}")
|
|
3065
3297
|
else:
|
|
@@ -3067,7 +3299,15 @@ for quiz in quiz_list:
|
|
|
3067
3299
|
@
|
|
3068
3300
|
|
|
3069
3301
|
In interactive mode, we process each quiz one at a time, opening the editor
|
|
3070
|
-
and showing a preview before applying changes.
|
|
3302
|
+
and showing a preview before applying changes. The [[--full-json]] flag
|
|
3303
|
+
selects between two editing modes:
|
|
3304
|
+
\begin{description}
|
|
3305
|
+
\item[YAML+Markdown] (default) Simple front matter with Markdown body for
|
|
3306
|
+
instructions. Good for quick edits to title, due dates, and instructions.
|
|
3307
|
+
\item[Full JSON] (with [[--full-json]]) Complete JSON export format, including
|
|
3308
|
+
[[quiz_settings]] with [[multiple_attempts]] and [[result_view_settings]].
|
|
3309
|
+
Optionally includes items if [[--replace-items]] is also specified.
|
|
3310
|
+
\end{description}
|
|
3071
3311
|
|
|
3072
3312
|
<<edit quiz interactively>>=
|
|
3073
3313
|
# Confirm if multiple quizzes match
|
|
@@ -3085,7 +3325,13 @@ updated_count = 0
|
|
|
3085
3325
|
skipped_count = 0
|
|
3086
3326
|
|
|
3087
3327
|
for quiz in quiz_list:
|
|
3088
|
-
|
|
3328
|
+
if args.full_json:
|
|
3329
|
+
result = edit_quiz_interactive_json(
|
|
3330
|
+
quiz, requester, args.html, args.replace_items
|
|
3331
|
+
)
|
|
3332
|
+
else:
|
|
3333
|
+
result = edit_quiz_interactive(quiz, requester, args.html)
|
|
3334
|
+
|
|
3089
3335
|
if result == 'updated':
|
|
3090
3336
|
updated_count += 1
|
|
3091
3337
|
elif result == 'skipped':
|
|
@@ -3154,6 +3400,416 @@ def edit_quiz_interactive(quiz, requester, html_mode=False):
|
|
|
3154
3400
|
@
|
|
3155
3401
|
|
|
3156
3402
|
|
|
3403
|
+
\subsection{Interactive JSON editing workflow}
|
|
3404
|
+
|
|
3405
|
+
The [[--full-json]] mode provides access to all quiz settings, including the
|
|
3406
|
+
nested [[quiz_settings]] structure that controls [[multiple_attempts]] and
|
|
3407
|
+
[[result_view_settings]]. This uses the same JSON format as [[quizzes export]],
|
|
3408
|
+
enabling a round-trip workflow.
|
|
3409
|
+
|
|
3410
|
+
The workflow is:
|
|
3411
|
+
\begin{enumerate}
|
|
3412
|
+
\item Export the quiz to JSON (same format as [[quizzes export -I]])
|
|
3413
|
+
\item Open the JSON in the user's editor
|
|
3414
|
+
\item Show a diff of the changes and ask for confirmation
|
|
3415
|
+
\item Apply the changes (settings only, unless [[--replace-items]])
|
|
3416
|
+
\end{enumerate}
|
|
3417
|
+
|
|
3418
|
+
<<functions>>=
|
|
3419
|
+
def edit_quiz_interactive_json(quiz, requester, html_mode=False,
|
|
3420
|
+
replace_items=False):
|
|
3421
|
+
"""Edit a quiz interactively using full JSON format
|
|
3422
|
+
|
|
3423
|
+
Args:
|
|
3424
|
+
quiz: Quiz object to edit
|
|
3425
|
+
requester: Canvas API requester
|
|
3426
|
+
html_mode: If True, don't convert instructions (not used in JSON mode)
|
|
3427
|
+
replace_items: If True, also update items from the JSON
|
|
3428
|
+
|
|
3429
|
+
Returns:
|
|
3430
|
+
'updated', 'skipped', or 'error'
|
|
3431
|
+
"""
|
|
3432
|
+
import tempfile
|
|
3433
|
+
|
|
3434
|
+
# Export current quiz state to JSON
|
|
3435
|
+
if is_new_quiz(quiz):
|
|
3436
|
+
original_data = export_full_new_quiz(quiz, requester, include_banks=True,
|
|
3437
|
+
importable=not replace_items)
|
|
3438
|
+
else:
|
|
3439
|
+
original_data = export_full_classic_quiz(quiz, importable=not replace_items)
|
|
3440
|
+
|
|
3441
|
+
# If not replacing items, remove items from the export to simplify editing
|
|
3442
|
+
if not replace_items:
|
|
3443
|
+
original_data.pop('items', None)
|
|
3444
|
+
original_data.pop('questions', None)
|
|
3445
|
+
original_json = json.dumps(original_data, indent=2, ensure_ascii=False)
|
|
3446
|
+
|
|
3447
|
+
# Create temp file with .json extension for editor syntax highlighting
|
|
3448
|
+
with tempfile.NamedTemporaryFile(
|
|
3449
|
+
mode='w', suffix='.json', delete=False, encoding='utf-8'
|
|
3450
|
+
) as f:
|
|
3451
|
+
f.write(original_json)
|
|
3452
|
+
temp_path = f.name
|
|
3453
|
+
|
|
3454
|
+
try:
|
|
3455
|
+
while True:
|
|
3456
|
+
# Open editor
|
|
3457
|
+
edited_json = open_in_editor(temp_path)
|
|
3458
|
+
if edited_json is None:
|
|
3459
|
+
print("Editor cancelled.", file=sys.stderr)
|
|
3460
|
+
return 'skipped'
|
|
3461
|
+
|
|
3462
|
+
# Parse the edited JSON
|
|
3463
|
+
try:
|
|
3464
|
+
edited_data = json.loads(edited_json)
|
|
3465
|
+
except json.JSONDecodeError as e:
|
|
3466
|
+
print(f"Invalid JSON: {e}", file=sys.stderr)
|
|
3467
|
+
response = input("Edit again? [Y/n] ").strip().lower()
|
|
3468
|
+
if response == 'n':
|
|
3469
|
+
return 'skipped'
|
|
3470
|
+
continue
|
|
3471
|
+
|
|
3472
|
+
# Show diff and confirm
|
|
3473
|
+
result = show_json_diff_and_confirm(
|
|
3474
|
+
original_json, edited_json, quiz.title
|
|
3475
|
+
)
|
|
3476
|
+
|
|
3477
|
+
if result == 'accept':
|
|
3478
|
+
break
|
|
3479
|
+
elif result == 'edit':
|
|
3480
|
+
# Update temp file with edited content for next iteration
|
|
3481
|
+
with open(temp_path, 'w', encoding='utf-8') as f:
|
|
3482
|
+
f.write(edited_json)
|
|
3483
|
+
continue
|
|
3484
|
+
else: # discard
|
|
3485
|
+
print("Discarded changes.", file=sys.stderr)
|
|
3486
|
+
return 'skipped'
|
|
3487
|
+
finally:
|
|
3488
|
+
# Clean up temp file
|
|
3489
|
+
try:
|
|
3490
|
+
os.unlink(temp_path)
|
|
3491
|
+
except OSError:
|
|
3492
|
+
pass
|
|
3493
|
+
|
|
3494
|
+
# Apply the changes
|
|
3495
|
+
success = apply_quiz_from_dict(
|
|
3496
|
+
quiz, edited_data, requester, replace_items=replace_items
|
|
3497
|
+
)
|
|
3498
|
+
return 'updated' if success else 'error'
|
|
3499
|
+
@
|
|
3500
|
+
|
|
3501
|
+
\paragraph{Opening the editor.}
|
|
3502
|
+
We use the [[EDITOR]] environment variable, falling back to common editors.
|
|
3503
|
+
|
|
3504
|
+
<<functions>>=
|
|
3505
|
+
def open_in_editor(filepath):
|
|
3506
|
+
"""Open a file in the user's preferred editor
|
|
3507
|
+
|
|
3508
|
+
Args:
|
|
3509
|
+
filepath: Path to the file to edit
|
|
3510
|
+
|
|
3511
|
+
Returns:
|
|
3512
|
+
The edited file content, or None if editor failed/was cancelled
|
|
3513
|
+
"""
|
|
3514
|
+
import subprocess
|
|
3515
|
+
|
|
3516
|
+
editor = os.environ.get('EDITOR', os.environ.get('VISUAL', 'vi'))
|
|
3517
|
+
|
|
3518
|
+
try:
|
|
3519
|
+
subprocess.run([editor, filepath], check=True)
|
|
3520
|
+
except subprocess.CalledProcessError:
|
|
3521
|
+
return None
|
|
3522
|
+
except FileNotFoundError:
|
|
3523
|
+
print(f"Editor '{editor}' not found. Set EDITOR environment variable.",
|
|
3524
|
+
file=sys.stderr)
|
|
3525
|
+
return None
|
|
3526
|
+
|
|
3527
|
+
try:
|
|
3528
|
+
with open(filepath, 'r', encoding='utf-8') as f:
|
|
3529
|
+
return f.read()
|
|
3530
|
+
except IOError:
|
|
3531
|
+
return None
|
|
3532
|
+
@
|
|
3533
|
+
|
|
3534
|
+
\paragraph{Showing the diff and confirming.}
|
|
3535
|
+
We show a unified diff of the changes and ask the user to accept, edit again,
|
|
3536
|
+
or discard.
|
|
3537
|
+
|
|
3538
|
+
<<functions>>=
|
|
3539
|
+
def show_json_diff_and_confirm(original, edited, title):
|
|
3540
|
+
"""Show a diff between original and edited JSON, ask for confirmation
|
|
3541
|
+
|
|
3542
|
+
Args:
|
|
3543
|
+
original: Original JSON string
|
|
3544
|
+
edited: Edited JSON string
|
|
3545
|
+
title: Quiz title for display
|
|
3546
|
+
|
|
3547
|
+
Returns:
|
|
3548
|
+
'accept', 'edit', or 'discard'
|
|
3549
|
+
"""
|
|
3550
|
+
if original.strip() == edited.strip():
|
|
3551
|
+
print("No changes detected.")
|
|
3552
|
+
return 'discard'
|
|
3553
|
+
|
|
3554
|
+
# Generate unified diff
|
|
3555
|
+
original_lines = original.splitlines(keepends=True)
|
|
3556
|
+
edited_lines = edited.splitlines(keepends=True)
|
|
3557
|
+
|
|
3558
|
+
diff = list(difflib.unified_diff(
|
|
3559
|
+
original_lines, edited_lines,
|
|
3560
|
+
fromfile='original', tofile='edited',
|
|
3561
|
+
lineterm=''
|
|
3562
|
+
))
|
|
3563
|
+
|
|
3564
|
+
if not diff:
|
|
3565
|
+
print("No changes detected.")
|
|
3566
|
+
return 'discard'
|
|
3567
|
+
|
|
3568
|
+
# Display diff with colors if terminal supports it
|
|
3569
|
+
print(f"\n--- Changes to: {title} ---")
|
|
3570
|
+
for line in diff:
|
|
3571
|
+
line = line.rstrip('\n')
|
|
3572
|
+
if line.startswith('+') and not line.startswith('+++'):
|
|
3573
|
+
print(f"\033[32m{line}\033[0m") # Green for additions
|
|
3574
|
+
elif line.startswith('-') and not line.startswith('---'):
|
|
3575
|
+
print(f"\033[31m{line}\033[0m") # Red for deletions
|
|
3576
|
+
elif line.startswith('@@'):
|
|
3577
|
+
print(f"\033[36m{line}\033[0m") # Cyan for line numbers
|
|
3578
|
+
else:
|
|
3579
|
+
print(line)
|
|
3580
|
+
print()
|
|
3581
|
+
|
|
3582
|
+
# Prompt for action
|
|
3583
|
+
while True:
|
|
3584
|
+
response = input("[A]ccept, [E]dit, [D]iscard? ").strip().lower()
|
|
3585
|
+
if response in ('a', 'accept'):
|
|
3586
|
+
return 'accept'
|
|
3587
|
+
elif response in ('e', 'edit'):
|
|
3588
|
+
return 'edit'
|
|
3589
|
+
elif response in ('d', 'discard'):
|
|
3590
|
+
return 'discard'
|
|
3591
|
+
print("Please enter A, E, or D.")
|
|
3592
|
+
@
|
|
3593
|
+
|
|
3594
|
+
\paragraph{Applying changes from JSON.}
|
|
3595
|
+
We extract settings from the edited JSON and apply them. If
|
|
3596
|
+
[[replace_items]] is True and items are present, we also update questions.
|
|
3597
|
+
|
|
3598
|
+
<<functions>>=
|
|
3599
|
+
def apply_quiz_from_dict(quiz, data, requester, replace_items=False):
|
|
3600
|
+
"""Apply quiz changes from a dictionary (parsed JSON/YAML)
|
|
3601
|
+
|
|
3602
|
+
Args:
|
|
3603
|
+
quiz: Quiz object to update
|
|
3604
|
+
data: Dictionary with settings and optional items
|
|
3605
|
+
requester: Canvas API requester
|
|
3606
|
+
replace_items: If True, replace quiz items
|
|
3607
|
+
|
|
3608
|
+
Returns:
|
|
3609
|
+
True on success, False on failure
|
|
3610
|
+
"""
|
|
3611
|
+
# Extract settings - handle both 'settings' key and flat structure
|
|
3612
|
+
if 'settings' in data:
|
|
3613
|
+
settings = data['settings'].copy()
|
|
3614
|
+
else:
|
|
3615
|
+
# Flat structure: everything except 'items' and 'quiz_type' is settings
|
|
3616
|
+
settings = {k: v for k, v in data.items()
|
|
3617
|
+
if k not in ('items', 'questions', 'quiz_type')}
|
|
3618
|
+
|
|
3619
|
+
# Extract instructions/body
|
|
3620
|
+
body = settings.get('instructions', '') or ''
|
|
3621
|
+
|
|
3622
|
+
# Apply settings
|
|
3623
|
+
success = apply_quiz_edit(quiz, settings, body, requester, html_mode=True)
|
|
3624
|
+
|
|
3625
|
+
if not success:
|
|
3626
|
+
return False
|
|
3627
|
+
|
|
3628
|
+
# Handle items if requested
|
|
3629
|
+
items = data.get('items') or data.get('questions')
|
|
3630
|
+
if replace_items and items:
|
|
3631
|
+
item_success = replace_quiz_items(quiz, items, requester)
|
|
3632
|
+
if not item_success:
|
|
3633
|
+
canvaslms.cli.warn("Settings updated but failed to replace items")
|
|
3634
|
+
return False
|
|
3635
|
+
|
|
3636
|
+
return True
|
|
3637
|
+
@
|
|
3638
|
+
|
|
3639
|
+
|
|
3640
|
+
\subsection{Submission checking and item replacement}
|
|
3641
|
+
|
|
3642
|
+
Before replacing quiz items, we should check if students have already started
|
|
3643
|
+
the quiz. Replacing items after students have submitted could invalidate their
|
|
3644
|
+
work, so we warn and ask for confirmation.
|
|
3645
|
+
|
|
3646
|
+
<<functions>>=
|
|
3647
|
+
def get_quiz_submission_count(quiz, requester):
|
|
3648
|
+
"""Get the number of student submissions for a quiz
|
|
3649
|
+
|
|
3650
|
+
Args:
|
|
3651
|
+
quiz: Quiz object
|
|
3652
|
+
requester: Canvas API requester (unused for New Quizzes)
|
|
3653
|
+
|
|
3654
|
+
Returns:
|
|
3655
|
+
Number of submissions, or -1 if unable to determine
|
|
3656
|
+
"""
|
|
3657
|
+
try:
|
|
3658
|
+
if is_new_quiz(quiz):
|
|
3659
|
+
# New Quizzes are assignments - use standard Canvas API.
|
|
3660
|
+
# The quiz.id is actually the assignment_id.
|
|
3661
|
+
# Note: canvasapi returns NewQuiz.id as string (bug/inconsistency),
|
|
3662
|
+
# but get_assignment() requires int.
|
|
3663
|
+
assignment = quiz.course.get_assignment(int(quiz.id))
|
|
3664
|
+
submissions = list(assignment.get_submissions())
|
|
3665
|
+
# Count submissions that have been submitted (not just placeholder records)
|
|
3666
|
+
return sum(1 for s in submissions if s.workflow_state == 'submitted'
|
|
3667
|
+
or s.workflow_state == 'graded'
|
|
3668
|
+
or getattr(s, 'submitted_at', None) is not None)
|
|
3669
|
+
else:
|
|
3670
|
+
# Classic Quiz: use canvasapi
|
|
3671
|
+
submissions = list(quiz.get_submissions())
|
|
3672
|
+
# Count only actual submissions (not just generated records)
|
|
3673
|
+
return sum(1 for s in submissions if s.workflow_state != 'settings_only')
|
|
3674
|
+
except Exception as e:
|
|
3675
|
+
canvaslms.cli.warn(f"Could not check submissions: {e}")
|
|
3676
|
+
return -1
|
|
3677
|
+
@
|
|
3678
|
+
|
|
3679
|
+
The [[replace_quiz_items]] function handles the complete process of replacing
|
|
3680
|
+
quiz items. It first checks for submissions, then deletes existing items,
|
|
3681
|
+
and finally creates the new items.
|
|
3682
|
+
|
|
3683
|
+
<<functions>>=
|
|
3684
|
+
def replace_quiz_items(quiz, items, requester):
|
|
3685
|
+
"""Replace all items in a quiz with new ones
|
|
3686
|
+
|
|
3687
|
+
Args:
|
|
3688
|
+
quiz: Quiz object
|
|
3689
|
+
items: List of item dictionaries (from export format)
|
|
3690
|
+
requester: Canvas API requester
|
|
3691
|
+
|
|
3692
|
+
Returns:
|
|
3693
|
+
True on success, False on failure
|
|
3694
|
+
"""
|
|
3695
|
+
# Check for submissions first
|
|
3696
|
+
submission_count = get_quiz_submission_count(quiz, requester)
|
|
3697
|
+
|
|
3698
|
+
if submission_count > 0:
|
|
3699
|
+
print(f"\nWarning: This quiz has {submission_count} student submission(s).",
|
|
3700
|
+
file=sys.stderr)
|
|
3701
|
+
print("Replacing items will invalidate existing responses!", file=sys.stderr)
|
|
3702
|
+
response = input("Continue anyway? [y/N] ").strip().lower()
|
|
3703
|
+
if response != 'y':
|
|
3704
|
+
print("Item replacement cancelled.")
|
|
3705
|
+
return False
|
|
3706
|
+
elif submission_count < 0:
|
|
3707
|
+
print("\nWarning: Could not determine submission count.", file=sys.stderr)
|
|
3708
|
+
response = input("Continue with item replacement? [y/N] ").strip().lower()
|
|
3709
|
+
if response != 'y':
|
|
3710
|
+
print("Item replacement cancelled.")
|
|
3711
|
+
return False
|
|
3712
|
+
|
|
3713
|
+
# Delete existing items
|
|
3714
|
+
if is_new_quiz(quiz):
|
|
3715
|
+
delete_success = delete_all_new_quiz_items(quiz, requester)
|
|
3716
|
+
else:
|
|
3717
|
+
delete_success = delete_all_classic_quiz_questions(quiz)
|
|
3718
|
+
|
|
3719
|
+
if not delete_success:
|
|
3720
|
+
canvaslms.cli.warn("Failed to delete existing items")
|
|
3721
|
+
return False
|
|
3722
|
+
|
|
3723
|
+
# Create new items
|
|
3724
|
+
if is_new_quiz(quiz):
|
|
3725
|
+
create_success = add_new_quiz_items(quiz.course, quiz.id, requester, items)
|
|
3726
|
+
else:
|
|
3727
|
+
create_success = add_classic_questions(quiz, items)
|
|
3728
|
+
|
|
3729
|
+
return create_success
|
|
3730
|
+
@
|
|
3731
|
+
|
|
3732
|
+
\paragraph{Deleting New Quiz items.}
|
|
3733
|
+
We first fetch all existing items, then delete each one.
|
|
3734
|
+
|
|
3735
|
+
<<functions>>=
|
|
3736
|
+
def delete_all_new_quiz_items(quiz, requester):
|
|
3737
|
+
"""Delete all items from a New Quiz
|
|
3738
|
+
|
|
3739
|
+
Args:
|
|
3740
|
+
quiz: Quiz object
|
|
3741
|
+
requester: Canvas API requester
|
|
3742
|
+
|
|
3743
|
+
Returns:
|
|
3744
|
+
True on success, False on failure
|
|
3745
|
+
"""
|
|
3746
|
+
try:
|
|
3747
|
+
# Fetch existing items
|
|
3748
|
+
endpoint = f"courses/{quiz.course.id}/quizzes/{quiz.id}/items"
|
|
3749
|
+
response = requester.request(
|
|
3750
|
+
method='GET',
|
|
3751
|
+
endpoint=endpoint,
|
|
3752
|
+
_url="new_quizzes"
|
|
3753
|
+
)
|
|
3754
|
+
items = response.json()
|
|
3755
|
+
|
|
3756
|
+
if not items:
|
|
3757
|
+
return True # Nothing to delete
|
|
3758
|
+
|
|
3759
|
+
# Delete each item
|
|
3760
|
+
for item in items:
|
|
3761
|
+
item_id = item.get('id')
|
|
3762
|
+
if item_id:
|
|
3763
|
+
delete_endpoint = f"courses/{quiz.course.id}/quizzes/{quiz.id}/items/{item_id}"
|
|
3764
|
+
requester.request(
|
|
3765
|
+
method='DELETE',
|
|
3766
|
+
endpoint=delete_endpoint,
|
|
3767
|
+
_url="new_quizzes"
|
|
3768
|
+
)
|
|
3769
|
+
|
|
3770
|
+
return True
|
|
3771
|
+
except Exception as e:
|
|
3772
|
+
canvaslms.cli.warn(f"Failed to delete New Quiz items: {e}")
|
|
3773
|
+
return False
|
|
3774
|
+
@
|
|
3775
|
+
|
|
3776
|
+
\paragraph{Deleting Classic Quiz questions.}
|
|
3777
|
+
For Classic Quizzes, we use the canvasapi library.
|
|
3778
|
+
|
|
3779
|
+
<<functions>>=
|
|
3780
|
+
def delete_all_classic_quiz_questions(quiz):
|
|
3781
|
+
"""Delete all questions from a Classic Quiz
|
|
3782
|
+
|
|
3783
|
+
Args:
|
|
3784
|
+
quiz: Classic Quiz object
|
|
3785
|
+
|
|
3786
|
+
Returns:
|
|
3787
|
+
True on success, False on failure
|
|
3788
|
+
"""
|
|
3789
|
+
try:
|
|
3790
|
+
questions = list(quiz.get_questions())
|
|
3791
|
+
|
|
3792
|
+
for question in questions:
|
|
3793
|
+
question.delete()
|
|
3794
|
+
|
|
3795
|
+
return True
|
|
3796
|
+
except Exception as e:
|
|
3797
|
+
canvaslms.cli.warn(f"Failed to delete Classic Quiz questions: {e}")
|
|
3798
|
+
return False
|
|
3799
|
+
@
|
|
3800
|
+
|
|
3801
|
+
The chunk [[<<check submissions and replace items>>]] combines submission
|
|
3802
|
+
checking with item replacement for the file-based workflow:
|
|
3803
|
+
|
|
3804
|
+
<<check submissions and replace items>>=
|
|
3805
|
+
item_success = replace_quiz_items(quiz, items, requester)
|
|
3806
|
+
if item_success:
|
|
3807
|
+
print(f"Replaced items for quiz: {quiz.title}")
|
|
3808
|
+
else:
|
|
3809
|
+
canvaslms.cli.warn(f"Failed to replace items for quiz: {quiz.title}")
|
|
3810
|
+
@
|
|
3811
|
+
|
|
3812
|
+
|
|
3157
3813
|
\subsection{Extracting quiz attributes}
|
|
3158
3814
|
|
|
3159
3815
|
To populate the editor with current quiz values, we extract attributes from
|
|
@@ -6036,21 +6692,33 @@ def ensure_uuids_in_entry(entry):
|
|
|
6036
6692
|
new_choices = []
|
|
6037
6693
|
|
|
6038
6694
|
for i, choice in enumerate(interaction_data['choices']):
|
|
6039
|
-
|
|
6040
|
-
|
|
6041
|
-
|
|
6042
|
-
|
|
6043
|
-
|
|
6044
|
-
|
|
6695
|
+
# Handle both dict choices (choice, multi-answer) and string choices (ordering)
|
|
6696
|
+
if isinstance(choice, str):
|
|
6697
|
+
# Ordering questions have choices as plain UUIDs/strings
|
|
6698
|
+
# The string itself is the ID - keep it or generate new if invalid
|
|
6699
|
+
if choice and len(choice) > 10: # Looks like a UUID
|
|
6700
|
+
new_id = choice
|
|
6701
|
+
else:
|
|
6702
|
+
new_id = str(uuid.uuid4())
|
|
6703
|
+
position_to_uuid[i + 1] = new_id
|
|
6704
|
+
new_choices.append(new_id)
|
|
6045
6705
|
else:
|
|
6046
|
-
|
|
6706
|
+
# Regular choice dict
|
|
6707
|
+
old_id = choice.get('id')
|
|
6708
|
+
position = choice.get('position', i + 1)
|
|
6709
|
+
|
|
6710
|
+
# Generate new UUID if missing
|
|
6711
|
+
if not old_id:
|
|
6712
|
+
new_id = str(uuid.uuid4())
|
|
6713
|
+
else:
|
|
6714
|
+
new_id = old_id
|
|
6047
6715
|
|
|
6048
|
-
|
|
6716
|
+
position_to_uuid[position] = new_id
|
|
6049
6717
|
|
|
6050
|
-
|
|
6051
|
-
|
|
6052
|
-
|
|
6053
|
-
|
|
6718
|
+
new_choice = dict(choice)
|
|
6719
|
+
new_choice['id'] = new_id
|
|
6720
|
+
new_choice['position'] = position
|
|
6721
|
+
new_choices.append(new_choice)
|
|
6054
6722
|
|
|
6055
6723
|
interaction_data['choices'] = new_choices
|
|
6056
6724
|
entry['interaction_data'] = interaction_data
|