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/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
- attributes, body = content.read_content_from_file(args.file)
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
- # If id is specified, find that specific quiz
2242
- if attributes.get("id"):
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(attributes["id"]):
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 {attributes['id']} not found")
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
- success = apply_quiz_edit(quiz, attributes, body, requester, args.html)
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
- result = edit_quiz_interactive(quiz, requester, args.html)
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
- old_id = choice.get("id")
3792
- position = choice.get("position", i + 1)
3793
-
3794
- # Generate new UUID if missing
3795
- if not old_id:
3796
- new_id = str(uuid.uuid4())
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
- new_id = old_id
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
- position_to_uuid[position] = new_id
4365
+ position_to_uuid[position] = new_id
3801
4366
 
3802
- new_choice = dict(choice)
3803
- new_choice["id"] = new_id
3804
- new_choice["position"] = position
3805
- new_choices.append(new_choice)
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
- Without -f: Opens your editor with YAML front matter (settings) and
4820
- Markdown body (instructions). After editing, shows a preview and asks
4821
- whether to accept, edit further, or discard the changes.
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
- With -f: Reads content from a file for scripted workflows. The file
4824
- should have YAML front matter followed by Markdown (or HTML with --html).
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 a Markdown/HTML file with YAML front matter",
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"""