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 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
- is now optional---without it, interactive mode is used. The [[--html]] option
2984
- preserves raw HTML instead of converting to Markdown.
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
- Without -f: Opens your editor with YAML front matter (settings) and
2992
- Markdown body (instructions). After editing, shows a preview and asks
2993
- whether to accept, edit further, or discard the changes.
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
- With -f: Reads content from a file for scripted workflows. The file
2996
- should have YAML front matter followed by Markdown (or HTML with --html).
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 a Markdown/HTML file with YAML front matter",
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 read the YAML front matter and body from the file, then
3039
- apply the changes to the quiz. If the file contains an [[id]] field, we use
3040
- it to identify the quiz directly; otherwise we use the filter match.
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
- attributes, body = content.read_content_from_file(args.file)
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
- # If id is specified, find that specific quiz
3051
- if attributes.get('id'):
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(attributes['id']):
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 {attributes['id']} not found")
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
- success = apply_quiz_edit(quiz, attributes, body, requester, args.html)
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
- result = edit_quiz_interactive(quiz, requester, args.html)
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
- old_id = choice.get('id')
6040
- position = choice.get('position', i + 1)
6041
-
6042
- # Generate new UUID if missing
6043
- if not old_id:
6044
- new_id = str(uuid.uuid4())
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
- new_id = old_id
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
- position_to_uuid[position] = new_id
6716
+ position_to_uuid[position] = new_id
6049
6717
 
6050
- new_choice = dict(choice)
6051
- new_choice['id'] = new_id
6052
- new_choice['position'] = position
6053
- new_choices.append(new_choice)
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