canvaslms 5.4__py3-none-any.whl → 5.6__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/modules.nw +22 -7
- canvaslms/cli/modules.py +19 -4
- canvaslms/cli/quizzes.nw +767 -31
- canvaslms/cli/quizzes.py +621 -25
- {canvaslms-5.4.dist-info → canvaslms-5.6.dist-info}/METADATA +1 -1
- {canvaslms-5.4.dist-info → canvaslms-5.6.dist-info}/RECORD +9 -9
- {canvaslms-5.4.dist-info → canvaslms-5.6.dist-info}/WHEEL +0 -0
- {canvaslms-5.4.dist-info → canvaslms-5.6.dist-info}/entry_points.txt +0 -0
- {canvaslms-5.4.dist-info → canvaslms-5.6.dist-info}/licenses/LICENSE +0 -0
canvaslms/cli/quizzes.py
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import argparse
|
|
2
2
|
import csv
|
|
3
|
+
import difflib
|
|
3
4
|
import json
|
|
4
5
|
import os
|
|
5
6
|
import re
|
|
6
7
|
import statistics
|
|
7
8
|
import sys
|
|
8
9
|
import time
|
|
10
|
+
import yaml
|
|
9
11
|
from collections import defaultdict, Counter
|
|
10
12
|
from typing import Dict, List, Any
|
|
11
13
|
|
|
@@ -15,6 +17,7 @@ import canvaslms.cli
|
|
|
15
17
|
import canvaslms.cli.courses as courses
|
|
16
18
|
import canvaslms.cli.assignments as assignments
|
|
17
19
|
import canvaslms.cli.content as content
|
|
20
|
+
import canvaslms.cli.modules as modules
|
|
18
21
|
import canvaslms.cli.utils
|
|
19
22
|
from rich.console import Console
|
|
20
23
|
from rich.markdown import Markdown
|
|
@@ -2225,6 +2228,155 @@ def create_classic_quiz(course, quiz_params):
|
|
|
2225
2228
|
return None
|
|
2226
2229
|
|
|
2227
2230
|
|
|
2231
|
+
def update_quiz_module_membership(quiz, module_regexes):
|
|
2232
|
+
"""Update module membership for a quiz based on module regex list"""
|
|
2233
|
+
item_id = int(quiz.id) if is_new_quiz(quiz) else quiz.id
|
|
2234
|
+
added, removed = modules.update_item_modules(
|
|
2235
|
+
quiz.course, "Assignment", item_id, module_regexes
|
|
2236
|
+
)
|
|
2237
|
+
if added:
|
|
2238
|
+
print(f" Added to modules: {', '.join(added)}", file=sys.stderr)
|
|
2239
|
+
if removed:
|
|
2240
|
+
print(f" Removed from modules: {', '.join(removed)}", file=sys.stderr)
|
|
2241
|
+
|
|
2242
|
+
|
|
2243
|
+
def detect_quiz_file_format(filepath):
|
|
2244
|
+
"""Detect quiz file format from extension and content
|
|
2245
|
+
|
|
2246
|
+
Args:
|
|
2247
|
+
filepath: Path to the file
|
|
2248
|
+
|
|
2249
|
+
Returns:
|
|
2250
|
+
'json', 'yaml', or 'frontmatter'
|
|
2251
|
+
|
|
2252
|
+
Raises:
|
|
2253
|
+
FileNotFoundError: If file doesn't exist
|
|
2254
|
+
ValueError: If format cannot be determined or extension mismatches content
|
|
2255
|
+
"""
|
|
2256
|
+
# Check extension
|
|
2257
|
+
ext = os.path.splitext(filepath)[1].lower()
|
|
2258
|
+
|
|
2259
|
+
# Read file content
|
|
2260
|
+
with open(filepath, "r", encoding="utf-8") as f:
|
|
2261
|
+
file_content = f.read()
|
|
2262
|
+
|
|
2263
|
+
content_stripped = file_content.lstrip()
|
|
2264
|
+
|
|
2265
|
+
# Determine expected format from extension
|
|
2266
|
+
if ext == ".json":
|
|
2267
|
+
expected = "json"
|
|
2268
|
+
elif ext in (".yaml", ".yml"):
|
|
2269
|
+
expected = "yaml"
|
|
2270
|
+
elif ext == ".md":
|
|
2271
|
+
expected = "frontmatter"
|
|
2272
|
+
else:
|
|
2273
|
+
expected = None # Auto-detect
|
|
2274
|
+
|
|
2275
|
+
# Detect actual format from content
|
|
2276
|
+
if content_stripped.startswith("{"):
|
|
2277
|
+
actual = "json"
|
|
2278
|
+
elif content_stripped.startswith("---"):
|
|
2279
|
+
actual = "frontmatter"
|
|
2280
|
+
elif content_stripped.startswith("quiz_type:") or content_stripped.startswith(
|
|
2281
|
+
"settings:"
|
|
2282
|
+
):
|
|
2283
|
+
actual = "yaml"
|
|
2284
|
+
else:
|
|
2285
|
+
# For YAML, try parsing to see if it's valid
|
|
2286
|
+
try:
|
|
2287
|
+
data = yaml.safe_load(content_stripped)
|
|
2288
|
+
if isinstance(data, dict) and ("settings" in data or "title" in data):
|
|
2289
|
+
actual = "yaml"
|
|
2290
|
+
else:
|
|
2291
|
+
raise ValueError(f"Cannot determine format of {filepath}")
|
|
2292
|
+
except yaml.YAMLError:
|
|
2293
|
+
raise ValueError(f"Cannot determine format of {filepath}")
|
|
2294
|
+
|
|
2295
|
+
# Verify match if extension specified a format
|
|
2296
|
+
if expected and expected != actual:
|
|
2297
|
+
raise ValueError(
|
|
2298
|
+
f"File extension suggests {expected} but content looks like {actual}"
|
|
2299
|
+
)
|
|
2300
|
+
|
|
2301
|
+
return actual, file_content
|
|
2302
|
+
|
|
2303
|
+
|
|
2304
|
+
def read_quiz_from_file(filepath):
|
|
2305
|
+
"""Read quiz data from JSON, YAML, or front matter file
|
|
2306
|
+
|
|
2307
|
+
Args:
|
|
2308
|
+
filepath: Path to the quiz file
|
|
2309
|
+
|
|
2310
|
+
Returns:
|
|
2311
|
+
Dictionary with:
|
|
2312
|
+
'format': 'json'|'yaml'|'frontmatter'
|
|
2313
|
+
'settings': dict of quiz settings
|
|
2314
|
+
'instructions': str or None (body for frontmatter format)
|
|
2315
|
+
'items': list or None (questions, for json/yaml)
|
|
2316
|
+
'quiz_type': 'new'|'classic' or None
|
|
2317
|
+
|
|
2318
|
+
Raises:
|
|
2319
|
+
FileNotFoundError: If file doesn't exist
|
|
2320
|
+
ValueError: If file format is invalid
|
|
2321
|
+
"""
|
|
2322
|
+
format_type, file_content = detect_quiz_file_format(filepath)
|
|
2323
|
+
|
|
2324
|
+
if format_type == "json":
|
|
2325
|
+
try:
|
|
2326
|
+
data = json.loads(file_content)
|
|
2327
|
+
except json.JSONDecodeError as e:
|
|
2328
|
+
raise ValueError(f"Invalid JSON: {e}")
|
|
2329
|
+
return _parse_quiz_full_format(data, "json")
|
|
2330
|
+
|
|
2331
|
+
elif format_type == "yaml":
|
|
2332
|
+
try:
|
|
2333
|
+
data = yaml.safe_load(file_content)
|
|
2334
|
+
except yaml.YAMLError as e:
|
|
2335
|
+
raise ValueError(f"Invalid YAML: {e}")
|
|
2336
|
+
return _parse_quiz_full_format(data, "yaml")
|
|
2337
|
+
|
|
2338
|
+
else: # frontmatter
|
|
2339
|
+
attributes, body = content.parse_yaml_front_matter(file_content)
|
|
2340
|
+
return {
|
|
2341
|
+
"format": "frontmatter",
|
|
2342
|
+
"settings": attributes,
|
|
2343
|
+
"instructions": body.strip() if body else None,
|
|
2344
|
+
"items": None,
|
|
2345
|
+
"quiz_type": None,
|
|
2346
|
+
}
|
|
2347
|
+
|
|
2348
|
+
|
|
2349
|
+
def _parse_quiz_full_format(data, format_type):
|
|
2350
|
+
"""Parse full format (JSON or YAML) quiz data
|
|
2351
|
+
|
|
2352
|
+
Handles both the full format with 'settings' key and the simple format
|
|
2353
|
+
where the entire dict is settings.
|
|
2354
|
+
"""
|
|
2355
|
+
if not isinstance(data, dict):
|
|
2356
|
+
raise ValueError(f"Expected a dictionary, got {type(data).__name__}")
|
|
2357
|
+
|
|
2358
|
+
if "settings" in data:
|
|
2359
|
+
settings = data["settings"].copy()
|
|
2360
|
+
else:
|
|
2361
|
+
# Simple format: entire dict is settings (excluding metadata keys)
|
|
2362
|
+
settings = {
|
|
2363
|
+
k: v
|
|
2364
|
+
for k, v in data.items()
|
|
2365
|
+
if k not in ("quiz_type", "items", "questions")
|
|
2366
|
+
}
|
|
2367
|
+
|
|
2368
|
+
# Extract instructions from settings if present
|
|
2369
|
+
instructions = settings.get("instructions")
|
|
2370
|
+
|
|
2371
|
+
return {
|
|
2372
|
+
"format": format_type,
|
|
2373
|
+
"settings": settings,
|
|
2374
|
+
"instructions": instructions,
|
|
2375
|
+
"items": data.get("items") or data.get("questions"),
|
|
2376
|
+
"quiz_type": data.get("quiz_type"),
|
|
2377
|
+
}
|
|
2378
|
+
|
|
2379
|
+
|
|
2228
2380
|
def edit_command(config, canvas, args):
|
|
2229
2381
|
"""Edits quiz settings and instructions"""
|
|
2230
2382
|
quiz_list = process_quiz_option(canvas, args)
|
|
@@ -2232,27 +2384,51 @@ def edit_command(config, canvas, args):
|
|
|
2232
2384
|
|
|
2233
2385
|
if args.file:
|
|
2234
2386
|
try:
|
|
2235
|
-
|
|
2387
|
+
quiz_data = read_quiz_from_file(args.file)
|
|
2236
2388
|
except FileNotFoundError:
|
|
2237
2389
|
canvaslms.cli.err(1, f"File not found: {args.file}")
|
|
2238
2390
|
except ValueError as e:
|
|
2239
2391
|
canvaslms.cli.err(1, f"Invalid file format: {e}")
|
|
2240
2392
|
|
|
2241
|
-
|
|
2242
|
-
|
|
2393
|
+
settings = quiz_data["settings"]
|
|
2394
|
+
quiz_id = settings.get("id")
|
|
2395
|
+
|
|
2396
|
+
if quiz_id:
|
|
2243
2397
|
target_quiz = None
|
|
2244
2398
|
for quiz in quiz_list:
|
|
2245
|
-
if str(quiz.id) == str(
|
|
2399
|
+
if str(quiz.id) == str(quiz_id):
|
|
2246
2400
|
target_quiz = quiz
|
|
2247
2401
|
break
|
|
2248
2402
|
if not target_quiz:
|
|
2249
|
-
canvaslms.cli.err(1, f"Quiz with ID {
|
|
2403
|
+
canvaslms.cli.err(1, f"Quiz with ID {quiz_id} not found")
|
|
2250
2404
|
quiz_list = [target_quiz]
|
|
2405
|
+
items = quiz_data.get("items")
|
|
2406
|
+
if args.replace_items and items:
|
|
2407
|
+
for quiz in quiz_list:
|
|
2408
|
+
item_success = replace_quiz_items(quiz, items, requester)
|
|
2409
|
+
if item_success:
|
|
2410
|
+
print(f"Replaced items for quiz: {quiz.title}")
|
|
2411
|
+
else:
|
|
2412
|
+
canvaslms.cli.warn(
|
|
2413
|
+
f"Failed to replace items for quiz: {quiz.title}"
|
|
2414
|
+
)
|
|
2415
|
+
elif items:
|
|
2416
|
+
print(
|
|
2417
|
+
f"Note: Ignoring {len(items)} items in file (use --replace-items to update)",
|
|
2418
|
+
file=sys.stderr,
|
|
2419
|
+
)
|
|
2420
|
+
body = quiz_data.get("instructions") or ""
|
|
2251
2421
|
|
|
2252
2422
|
for quiz in quiz_list:
|
|
2253
|
-
|
|
2423
|
+
# For JSON/YAML, instructions may be in settings; extract it for body
|
|
2424
|
+
if quiz_data["format"] in ("json", "yaml"):
|
|
2425
|
+
body = settings.get("instructions", "") or ""
|
|
2426
|
+
|
|
2427
|
+
success = apply_quiz_edit(quiz, settings, body, requester, args.html)
|
|
2254
2428
|
if success:
|
|
2255
2429
|
print(f"Updated quiz: {quiz.title}")
|
|
2430
|
+
if "modules" in settings:
|
|
2431
|
+
update_quiz_module_membership(quiz, settings["modules"])
|
|
2256
2432
|
else:
|
|
2257
2433
|
canvaslms.cli.warn(f"Failed to update quiz: {quiz.title}")
|
|
2258
2434
|
else:
|
|
@@ -2271,7 +2447,13 @@ def edit_command(config, canvas, args):
|
|
|
2271
2447
|
skipped_count = 0
|
|
2272
2448
|
|
|
2273
2449
|
for quiz in quiz_list:
|
|
2274
|
-
|
|
2450
|
+
if args.full_json:
|
|
2451
|
+
result = edit_quiz_interactive_json(
|
|
2452
|
+
quiz, requester, args.html, args.replace_items
|
|
2453
|
+
)
|
|
2454
|
+
else:
|
|
2455
|
+
result = edit_quiz_interactive(quiz, requester, args.html)
|
|
2456
|
+
|
|
2275
2457
|
if result == "updated":
|
|
2276
2458
|
updated_count += 1
|
|
2277
2459
|
elif result == "skipped":
|
|
@@ -2327,9 +2509,370 @@ def edit_quiz_interactive(quiz, requester, html_mode=False):
|
|
|
2327
2509
|
|
|
2328
2510
|
# Apply the changes
|
|
2329
2511
|
success = apply_quiz_edit(quiz, final_attrs, final_body, requester, html_mode)
|
|
2512
|
+
if success:
|
|
2513
|
+
if "modules" in final_attrs:
|
|
2514
|
+
update_quiz_module_membership(quiz, final_attrs["modules"])
|
|
2330
2515
|
return "updated" if success else "error"
|
|
2331
2516
|
|
|
2332
2517
|
|
|
2518
|
+
def edit_quiz_interactive_json(quiz, requester, html_mode=False, replace_items=False):
|
|
2519
|
+
"""Edit a quiz interactively using full JSON format
|
|
2520
|
+
|
|
2521
|
+
Args:
|
|
2522
|
+
quiz: Quiz object to edit
|
|
2523
|
+
requester: Canvas API requester
|
|
2524
|
+
html_mode: If True, don't convert instructions (not used in JSON mode)
|
|
2525
|
+
replace_items: If True, also update items from the JSON
|
|
2526
|
+
|
|
2527
|
+
Returns:
|
|
2528
|
+
'updated', 'skipped', or 'error'
|
|
2529
|
+
"""
|
|
2530
|
+
import tempfile
|
|
2531
|
+
|
|
2532
|
+
# Export current quiz state to JSON
|
|
2533
|
+
if is_new_quiz(quiz):
|
|
2534
|
+
original_data = export_full_new_quiz(
|
|
2535
|
+
quiz, requester, include_banks=True, importable=not replace_items
|
|
2536
|
+
)
|
|
2537
|
+
else:
|
|
2538
|
+
original_data = export_full_classic_quiz(quiz, importable=not replace_items)
|
|
2539
|
+
|
|
2540
|
+
# If not replacing items, remove items from the export to simplify editing
|
|
2541
|
+
if not replace_items:
|
|
2542
|
+
original_data.pop("items", None)
|
|
2543
|
+
original_data.pop("questions", None)
|
|
2544
|
+
original_json = json.dumps(original_data, indent=2, ensure_ascii=False)
|
|
2545
|
+
|
|
2546
|
+
# Create temp file with .json extension for editor syntax highlighting
|
|
2547
|
+
with tempfile.NamedTemporaryFile(
|
|
2548
|
+
mode="w", suffix=".json", delete=False, encoding="utf-8"
|
|
2549
|
+
) as f:
|
|
2550
|
+
f.write(original_json)
|
|
2551
|
+
temp_path = f.name
|
|
2552
|
+
|
|
2553
|
+
try:
|
|
2554
|
+
while True:
|
|
2555
|
+
# Open editor
|
|
2556
|
+
edited_json = open_in_editor(temp_path)
|
|
2557
|
+
if edited_json is None:
|
|
2558
|
+
print("Editor cancelled.", file=sys.stderr)
|
|
2559
|
+
return "skipped"
|
|
2560
|
+
|
|
2561
|
+
# Parse the edited JSON
|
|
2562
|
+
try:
|
|
2563
|
+
edited_data = json.loads(edited_json)
|
|
2564
|
+
except json.JSONDecodeError as e:
|
|
2565
|
+
print(f"Invalid JSON: {e}", file=sys.stderr)
|
|
2566
|
+
response = input("Edit again? [Y/n] ").strip().lower()
|
|
2567
|
+
if response == "n":
|
|
2568
|
+
return "skipped"
|
|
2569
|
+
continue
|
|
2570
|
+
|
|
2571
|
+
# Show diff and confirm
|
|
2572
|
+
result = show_json_diff_and_confirm(original_json, edited_json, quiz.title)
|
|
2573
|
+
|
|
2574
|
+
if result == "accept":
|
|
2575
|
+
break
|
|
2576
|
+
elif result == "edit":
|
|
2577
|
+
# Update temp file with edited content for next iteration
|
|
2578
|
+
with open(temp_path, "w", encoding="utf-8") as f:
|
|
2579
|
+
f.write(edited_json)
|
|
2580
|
+
continue
|
|
2581
|
+
else: # discard
|
|
2582
|
+
print("Discarded changes.", file=sys.stderr)
|
|
2583
|
+
return "skipped"
|
|
2584
|
+
finally:
|
|
2585
|
+
# Clean up temp file
|
|
2586
|
+
try:
|
|
2587
|
+
os.unlink(temp_path)
|
|
2588
|
+
except OSError:
|
|
2589
|
+
pass
|
|
2590
|
+
|
|
2591
|
+
# Apply the changes
|
|
2592
|
+
success = apply_quiz_from_dict(
|
|
2593
|
+
quiz, edited_data, requester, replace_items=replace_items
|
|
2594
|
+
)
|
|
2595
|
+
return "updated" if success else "error"
|
|
2596
|
+
|
|
2597
|
+
|
|
2598
|
+
def open_in_editor(filepath):
|
|
2599
|
+
"""Open a file in the user's preferred editor
|
|
2600
|
+
|
|
2601
|
+
Args:
|
|
2602
|
+
filepath: Path to the file to edit
|
|
2603
|
+
|
|
2604
|
+
Returns:
|
|
2605
|
+
The edited file content, or None if editor failed/was cancelled
|
|
2606
|
+
"""
|
|
2607
|
+
import subprocess
|
|
2608
|
+
|
|
2609
|
+
editor = os.environ.get("EDITOR", os.environ.get("VISUAL", "vi"))
|
|
2610
|
+
|
|
2611
|
+
try:
|
|
2612
|
+
subprocess.run([editor, filepath], check=True)
|
|
2613
|
+
except subprocess.CalledProcessError:
|
|
2614
|
+
return None
|
|
2615
|
+
except FileNotFoundError:
|
|
2616
|
+
print(
|
|
2617
|
+
f"Editor '{editor}' not found. Set EDITOR environment variable.",
|
|
2618
|
+
file=sys.stderr,
|
|
2619
|
+
)
|
|
2620
|
+
return None
|
|
2621
|
+
|
|
2622
|
+
try:
|
|
2623
|
+
with open(filepath, "r", encoding="utf-8") as f:
|
|
2624
|
+
return f.read()
|
|
2625
|
+
except IOError:
|
|
2626
|
+
return None
|
|
2627
|
+
|
|
2628
|
+
|
|
2629
|
+
def show_json_diff_and_confirm(original, edited, title):
|
|
2630
|
+
"""Show a diff between original and edited JSON, ask for confirmation
|
|
2631
|
+
|
|
2632
|
+
Args:
|
|
2633
|
+
original: Original JSON string
|
|
2634
|
+
edited: Edited JSON string
|
|
2635
|
+
title: Quiz title for display
|
|
2636
|
+
|
|
2637
|
+
Returns:
|
|
2638
|
+
'accept', 'edit', or 'discard'
|
|
2639
|
+
"""
|
|
2640
|
+
if original.strip() == edited.strip():
|
|
2641
|
+
print("No changes detected.")
|
|
2642
|
+
return "discard"
|
|
2643
|
+
|
|
2644
|
+
# Generate unified diff
|
|
2645
|
+
original_lines = original.splitlines(keepends=True)
|
|
2646
|
+
edited_lines = edited.splitlines(keepends=True)
|
|
2647
|
+
|
|
2648
|
+
diff = list(
|
|
2649
|
+
difflib.unified_diff(
|
|
2650
|
+
original_lines,
|
|
2651
|
+
edited_lines,
|
|
2652
|
+
fromfile="original",
|
|
2653
|
+
tofile="edited",
|
|
2654
|
+
lineterm="",
|
|
2655
|
+
)
|
|
2656
|
+
)
|
|
2657
|
+
|
|
2658
|
+
if not diff:
|
|
2659
|
+
print("No changes detected.")
|
|
2660
|
+
return "discard"
|
|
2661
|
+
|
|
2662
|
+
# Display diff with colors if terminal supports it
|
|
2663
|
+
print(f"\n--- Changes to: {title} ---")
|
|
2664
|
+
for line in diff:
|
|
2665
|
+
line = line.rstrip("\n")
|
|
2666
|
+
if line.startswith("+") and not line.startswith("+++"):
|
|
2667
|
+
print(f"\033[32m{line}\033[0m") # Green for additions
|
|
2668
|
+
elif line.startswith("-") and not line.startswith("---"):
|
|
2669
|
+
print(f"\033[31m{line}\033[0m") # Red for deletions
|
|
2670
|
+
elif line.startswith("@@"):
|
|
2671
|
+
print(f"\033[36m{line}\033[0m") # Cyan for line numbers
|
|
2672
|
+
else:
|
|
2673
|
+
print(line)
|
|
2674
|
+
print()
|
|
2675
|
+
|
|
2676
|
+
# Prompt for action
|
|
2677
|
+
while True:
|
|
2678
|
+
response = input("[A]ccept, [E]dit, [D]iscard? ").strip().lower()
|
|
2679
|
+
if response in ("a", "accept"):
|
|
2680
|
+
return "accept"
|
|
2681
|
+
elif response in ("e", "edit"):
|
|
2682
|
+
return "edit"
|
|
2683
|
+
elif response in ("d", "discard"):
|
|
2684
|
+
return "discard"
|
|
2685
|
+
print("Please enter A, E, or D.")
|
|
2686
|
+
|
|
2687
|
+
|
|
2688
|
+
def apply_quiz_from_dict(quiz, data, requester, replace_items=False):
|
|
2689
|
+
"""Apply quiz changes from a dictionary (parsed JSON/YAML)
|
|
2690
|
+
|
|
2691
|
+
Args:
|
|
2692
|
+
quiz: Quiz object to update
|
|
2693
|
+
data: Dictionary with settings and optional items
|
|
2694
|
+
requester: Canvas API requester
|
|
2695
|
+
replace_items: If True, replace quiz items
|
|
2696
|
+
|
|
2697
|
+
Returns:
|
|
2698
|
+
True on success, False on failure
|
|
2699
|
+
"""
|
|
2700
|
+
# Extract settings - handle both 'settings' key and flat structure
|
|
2701
|
+
if "settings" in data:
|
|
2702
|
+
settings = data["settings"].copy()
|
|
2703
|
+
else:
|
|
2704
|
+
# Flat structure: everything except 'items' and 'quiz_type' is settings
|
|
2705
|
+
settings = {
|
|
2706
|
+
k: v
|
|
2707
|
+
for k, v in data.items()
|
|
2708
|
+
if k not in ("items", "questions", "quiz_type")
|
|
2709
|
+
}
|
|
2710
|
+
|
|
2711
|
+
# Extract instructions/body
|
|
2712
|
+
body = settings.get("instructions", "") or ""
|
|
2713
|
+
|
|
2714
|
+
# Apply settings
|
|
2715
|
+
success = apply_quiz_edit(quiz, settings, body, requester, html_mode=True)
|
|
2716
|
+
|
|
2717
|
+
if not success:
|
|
2718
|
+
return False
|
|
2719
|
+
|
|
2720
|
+
# Handle items if requested
|
|
2721
|
+
items = data.get("items") or data.get("questions")
|
|
2722
|
+
if replace_items and items:
|
|
2723
|
+
item_success = replace_quiz_items(quiz, items, requester)
|
|
2724
|
+
if not item_success:
|
|
2725
|
+
canvaslms.cli.warn("Settings updated but failed to replace items")
|
|
2726
|
+
return False
|
|
2727
|
+
|
|
2728
|
+
return True
|
|
2729
|
+
|
|
2730
|
+
|
|
2731
|
+
def get_quiz_submission_count(quiz, requester):
|
|
2732
|
+
"""Get the number of student submissions for a quiz
|
|
2733
|
+
|
|
2734
|
+
Args:
|
|
2735
|
+
quiz: Quiz object
|
|
2736
|
+
requester: Canvas API requester (unused for New Quizzes)
|
|
2737
|
+
|
|
2738
|
+
Returns:
|
|
2739
|
+
Number of submissions, or -1 if unable to determine
|
|
2740
|
+
"""
|
|
2741
|
+
try:
|
|
2742
|
+
if is_new_quiz(quiz):
|
|
2743
|
+
# New Quizzes are assignments - use standard Canvas API.
|
|
2744
|
+
# The quiz.id is actually the assignment_id.
|
|
2745
|
+
# Note: canvasapi returns NewQuiz.id as string (bug/inconsistency),
|
|
2746
|
+
# but get_assignment() requires int.
|
|
2747
|
+
assignment = quiz.course.get_assignment(int(quiz.id))
|
|
2748
|
+
submissions = list(assignment.get_submissions())
|
|
2749
|
+
# Count submissions that have been submitted (not just placeholder records)
|
|
2750
|
+
return sum(
|
|
2751
|
+
1
|
|
2752
|
+
for s in submissions
|
|
2753
|
+
if s.workflow_state == "submitted"
|
|
2754
|
+
or s.workflow_state == "graded"
|
|
2755
|
+
or getattr(s, "submitted_at", None) is not None
|
|
2756
|
+
)
|
|
2757
|
+
else:
|
|
2758
|
+
# Classic Quiz: use canvasapi
|
|
2759
|
+
submissions = list(quiz.get_submissions())
|
|
2760
|
+
# Count only actual submissions (not just generated records)
|
|
2761
|
+
return sum(1 for s in submissions if s.workflow_state != "settings_only")
|
|
2762
|
+
except Exception as e:
|
|
2763
|
+
canvaslms.cli.warn(f"Could not check submissions: {e}")
|
|
2764
|
+
return -1
|
|
2765
|
+
|
|
2766
|
+
|
|
2767
|
+
def replace_quiz_items(quiz, items, requester):
|
|
2768
|
+
"""Replace all items in a quiz with new ones
|
|
2769
|
+
|
|
2770
|
+
Args:
|
|
2771
|
+
quiz: Quiz object
|
|
2772
|
+
items: List of item dictionaries (from export format)
|
|
2773
|
+
requester: Canvas API requester
|
|
2774
|
+
|
|
2775
|
+
Returns:
|
|
2776
|
+
True on success, False on failure
|
|
2777
|
+
"""
|
|
2778
|
+
# Check for submissions first
|
|
2779
|
+
submission_count = get_quiz_submission_count(quiz, requester)
|
|
2780
|
+
|
|
2781
|
+
if submission_count > 0:
|
|
2782
|
+
print(
|
|
2783
|
+
f"\nWarning: This quiz has {submission_count} student submission(s).",
|
|
2784
|
+
file=sys.stderr,
|
|
2785
|
+
)
|
|
2786
|
+
print("Replacing items will invalidate existing responses!", file=sys.stderr)
|
|
2787
|
+
response = input("Continue anyway? [y/N] ").strip().lower()
|
|
2788
|
+
if response != "y":
|
|
2789
|
+
print("Item replacement cancelled.")
|
|
2790
|
+
return False
|
|
2791
|
+
elif submission_count < 0:
|
|
2792
|
+
print("\nWarning: Could not determine submission count.", file=sys.stderr)
|
|
2793
|
+
response = input("Continue with item replacement? [y/N] ").strip().lower()
|
|
2794
|
+
if response != "y":
|
|
2795
|
+
print("Item replacement cancelled.")
|
|
2796
|
+
return False
|
|
2797
|
+
|
|
2798
|
+
# Delete existing items
|
|
2799
|
+
if is_new_quiz(quiz):
|
|
2800
|
+
delete_success = delete_all_new_quiz_items(quiz, requester)
|
|
2801
|
+
else:
|
|
2802
|
+
delete_success = delete_all_classic_quiz_questions(quiz)
|
|
2803
|
+
|
|
2804
|
+
if not delete_success:
|
|
2805
|
+
canvaslms.cli.warn("Failed to delete existing items")
|
|
2806
|
+
return False
|
|
2807
|
+
|
|
2808
|
+
# Create new items
|
|
2809
|
+
if is_new_quiz(quiz):
|
|
2810
|
+
create_success = add_new_quiz_items(quiz.course, quiz.id, requester, items)
|
|
2811
|
+
else:
|
|
2812
|
+
create_success = add_classic_questions(quiz, items)
|
|
2813
|
+
|
|
2814
|
+
return create_success
|
|
2815
|
+
|
|
2816
|
+
|
|
2817
|
+
def delete_all_new_quiz_items(quiz, requester):
|
|
2818
|
+
"""Delete all items from a New Quiz
|
|
2819
|
+
|
|
2820
|
+
Args:
|
|
2821
|
+
quiz: Quiz object
|
|
2822
|
+
requester: Canvas API requester
|
|
2823
|
+
|
|
2824
|
+
Returns:
|
|
2825
|
+
True on success, False on failure
|
|
2826
|
+
"""
|
|
2827
|
+
try:
|
|
2828
|
+
# Fetch existing items
|
|
2829
|
+
endpoint = f"courses/{quiz.course.id}/quizzes/{quiz.id}/items"
|
|
2830
|
+
response = requester.request(
|
|
2831
|
+
method="GET", endpoint=endpoint, _url="new_quizzes"
|
|
2832
|
+
)
|
|
2833
|
+
items = response.json()
|
|
2834
|
+
|
|
2835
|
+
if not items:
|
|
2836
|
+
return True # Nothing to delete
|
|
2837
|
+
|
|
2838
|
+
# Delete each item
|
|
2839
|
+
for item in items:
|
|
2840
|
+
item_id = item.get("id")
|
|
2841
|
+
if item_id:
|
|
2842
|
+
delete_endpoint = (
|
|
2843
|
+
f"courses/{quiz.course.id}/quizzes/{quiz.id}/items/{item_id}"
|
|
2844
|
+
)
|
|
2845
|
+
requester.request(
|
|
2846
|
+
method="DELETE", endpoint=delete_endpoint, _url="new_quizzes"
|
|
2847
|
+
)
|
|
2848
|
+
|
|
2849
|
+
return True
|
|
2850
|
+
except Exception as e:
|
|
2851
|
+
canvaslms.cli.warn(f"Failed to delete New Quiz items: {e}")
|
|
2852
|
+
return False
|
|
2853
|
+
|
|
2854
|
+
|
|
2855
|
+
def delete_all_classic_quiz_questions(quiz):
|
|
2856
|
+
"""Delete all questions from a Classic Quiz
|
|
2857
|
+
|
|
2858
|
+
Args:
|
|
2859
|
+
quiz: Classic Quiz object
|
|
2860
|
+
|
|
2861
|
+
Returns:
|
|
2862
|
+
True on success, False on failure
|
|
2863
|
+
"""
|
|
2864
|
+
try:
|
|
2865
|
+
questions = list(quiz.get_questions())
|
|
2866
|
+
|
|
2867
|
+
for question in questions:
|
|
2868
|
+
question.delete()
|
|
2869
|
+
|
|
2870
|
+
return True
|
|
2871
|
+
except Exception as e:
|
|
2872
|
+
canvaslms.cli.warn(f"Failed to delete Classic Quiz questions: {e}")
|
|
2873
|
+
return False
|
|
2874
|
+
|
|
2875
|
+
|
|
2333
2876
|
def extract_quiz_attributes(quiz, requester=None):
|
|
2334
2877
|
"""Extract editable attributes from a quiz object
|
|
2335
2878
|
|
|
@@ -2651,6 +3194,12 @@ def export_full_new_quiz(quiz, requester, include_banks=True, importable=False):
|
|
|
2651
3194
|
"""
|
|
2652
3195
|
# Extract basic settings
|
|
2653
3196
|
settings = {
|
|
3197
|
+
"modules": [
|
|
3198
|
+
"^" + re.escape(name) + "$"
|
|
3199
|
+
for name in modules.get_item_modules(
|
|
3200
|
+
quiz.course, "Assignment", int(quiz.id)
|
|
3201
|
+
)
|
|
3202
|
+
],
|
|
2654
3203
|
"title": getattr(quiz, "title", ""),
|
|
2655
3204
|
"instructions": getattr(quiz, "instructions", "") or "",
|
|
2656
3205
|
"time_limit": getattr(quiz, "time_limit", None),
|
|
@@ -2689,6 +3238,10 @@ def export_full_classic_quiz(quiz, importable=False):
|
|
|
2689
3238
|
"""
|
|
2690
3239
|
# Extract settings
|
|
2691
3240
|
settings = {
|
|
3241
|
+
"modules": [
|
|
3242
|
+
"^" + re.escape(name) + "$"
|
|
3243
|
+
for name in modules.get_item_modules(quiz.course, "Assignment", quiz.id)
|
|
3244
|
+
],
|
|
2692
3245
|
"title": getattr(quiz, "title", ""),
|
|
2693
3246
|
"description": getattr(quiz, "description", "") or "",
|
|
2694
3247
|
"quiz_type": getattr(quiz, "quiz_type", "assignment"),
|
|
@@ -3788,21 +4341,33 @@ def ensure_uuids_in_entry(entry):
|
|
|
3788
4341
|
new_choices = []
|
|
3789
4342
|
|
|
3790
4343
|
for i, choice in enumerate(interaction_data["choices"]):
|
|
3791
|
-
|
|
3792
|
-
|
|
3793
|
-
|
|
3794
|
-
|
|
3795
|
-
|
|
3796
|
-
|
|
4344
|
+
# Handle both dict choices (choice, multi-answer) and string choices (ordering)
|
|
4345
|
+
if isinstance(choice, str):
|
|
4346
|
+
# Ordering questions have choices as plain UUIDs/strings
|
|
4347
|
+
# The string itself is the ID - keep it or generate new if invalid
|
|
4348
|
+
if choice and len(choice) > 10: # Looks like a UUID
|
|
4349
|
+
new_id = choice
|
|
4350
|
+
else:
|
|
4351
|
+
new_id = str(uuid.uuid4())
|
|
4352
|
+
position_to_uuid[i + 1] = new_id
|
|
4353
|
+
new_choices.append(new_id)
|
|
3797
4354
|
else:
|
|
3798
|
-
|
|
4355
|
+
# Regular choice dict
|
|
4356
|
+
old_id = choice.get("id")
|
|
4357
|
+
position = choice.get("position", i + 1)
|
|
4358
|
+
|
|
4359
|
+
# Generate new UUID if missing
|
|
4360
|
+
if not old_id:
|
|
4361
|
+
new_id = str(uuid.uuid4())
|
|
4362
|
+
else:
|
|
4363
|
+
new_id = old_id
|
|
3799
4364
|
|
|
3800
|
-
|
|
4365
|
+
position_to_uuid[position] = new_id
|
|
3801
4366
|
|
|
3802
|
-
|
|
3803
|
-
|
|
3804
|
-
|
|
3805
|
-
|
|
4367
|
+
new_choice = dict(choice)
|
|
4368
|
+
new_choice["id"] = new_id
|
|
4369
|
+
new_choice["position"] = position
|
|
4370
|
+
new_choices.append(new_choice)
|
|
3806
4371
|
|
|
3807
4372
|
interaction_data["choices"] = new_choices
|
|
3808
4373
|
entry["interaction_data"] = interaction_data
|
|
@@ -4816,12 +5381,28 @@ def add_edit_command(subp):
|
|
|
4816
5381
|
help="Edit quiz settings and instructions",
|
|
4817
5382
|
description="""Edit an existing quiz's settings and instructions.
|
|
4818
5383
|
|
|
4819
|
-
|
|
4820
|
-
|
|
4821
|
-
|
|
5384
|
+
INTERACTIVE MODE (default):
|
|
5385
|
+
Opens your editor with YAML front matter (settings) and Markdown body
|
|
5386
|
+
(instructions). After editing, shows a preview and asks whether to
|
|
5387
|
+
accept, edit further, or discard the changes.
|
|
5388
|
+
|
|
5389
|
+
Use --full-json to edit as full JSON (same format as 'quizzes export -I').
|
|
5390
|
+
This allows editing all quiz_settings including multiple_attempts and
|
|
5391
|
+
result_view_settings.
|
|
4822
5392
|
|
|
4823
|
-
|
|
4824
|
-
|
|
5393
|
+
FILE MODE (-f):
|
|
5394
|
+
Reads content from a file. Format is auto-detected from extension:
|
|
5395
|
+
.json - Full JSON format (settings + optional items)
|
|
5396
|
+
.yaml/.yml - Full YAML format (same structure as JSON)
|
|
5397
|
+
.md - YAML front matter + Markdown body
|
|
5398
|
+
|
|
5399
|
+
The JSON/YAML format is the same as 'quizzes export' output, enabling
|
|
5400
|
+
a round-trip workflow: export, modify, edit.
|
|
5401
|
+
|
|
5402
|
+
ITEM HANDLING:
|
|
5403
|
+
By default, items/questions in the file are ignored to protect student
|
|
5404
|
+
submissions. Use --replace-items to replace all questions (with confirmation
|
|
5405
|
+
if submissions exist).
|
|
4825
5406
|
|
|
4826
5407
|
The quiz type (New or Classic) is auto-detected.""",
|
|
4827
5408
|
)
|
|
@@ -4838,7 +5419,7 @@ def add_edit_command(subp):
|
|
|
4838
5419
|
edit_parser.add_argument(
|
|
4839
5420
|
"-f",
|
|
4840
5421
|
"--file",
|
|
4841
|
-
help="Read content from
|
|
5422
|
+
help="Read content from file (format auto-detected: .json, .yaml, .yml, .md)",
|
|
4842
5423
|
type=str,
|
|
4843
5424
|
required=False,
|
|
4844
5425
|
)
|
|
@@ -4849,6 +5430,21 @@ def add_edit_command(subp):
|
|
|
4849
5430
|
help="Edit raw HTML instead of converting to Markdown",
|
|
4850
5431
|
)
|
|
4851
5432
|
|
|
5433
|
+
edit_parser.add_argument(
|
|
5434
|
+
"--full-json",
|
|
5435
|
+
action="store_true",
|
|
5436
|
+
help="Interactive mode: edit as full JSON instead of YAML+Markdown. "
|
|
5437
|
+
"Allows editing all quiz_settings including multiple_attempts.",
|
|
5438
|
+
)
|
|
5439
|
+
|
|
5440
|
+
edit_parser.add_argument(
|
|
5441
|
+
"--replace-items",
|
|
5442
|
+
action="store_true",
|
|
5443
|
+
help="Replace existing questions with items from file. "
|
|
5444
|
+
"Default: ignore items to preserve student attempts. "
|
|
5445
|
+
"Will prompt for confirmation if quiz has submissions.",
|
|
5446
|
+
)
|
|
5447
|
+
|
|
4852
5448
|
|
|
4853
5449
|
def add_delete_command(subp):
|
|
4854
5450
|
"""Adds the quizzes delete subcommand to argparse parser subp"""
|