canvaslms 5.2__py3-none-any.whl → 5.4__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
@@ -123,6 +123,74 @@ QUIZ_SCHEMA = {
123
123
  "description": "When to hide results: null, always, until_after_last_attempt",
124
124
  },
125
125
  }
126
+ NEW_QUIZ_MULTIPLE_ATTEMPTS_SCHEMA = {
127
+ "multiple_attempts_enabled": {
128
+ "default": False,
129
+ "description": "Whether multiple attempts are allowed",
130
+ },
131
+ "attempt_limit": {
132
+ "default": True,
133
+ "description": "Whether there is a maximum number of attempts (False = unlimited)",
134
+ },
135
+ "max_attempts": {
136
+ "default": 1,
137
+ "description": "Maximum number of attempts (only used if attempt_limit is True)",
138
+ },
139
+ "score_to_keep": {
140
+ "default": "highest",
141
+ "description": "Which score to keep: average, first, highest, or latest",
142
+ },
143
+ "cooling_period": {
144
+ "default": False,
145
+ "description": "Whether to require a waiting period between attempts",
146
+ },
147
+ "cooling_period_seconds": {
148
+ "default": None,
149
+ "description": "Required waiting time between attempts in seconds (e.g., 3600 = 1 hour)",
150
+ },
151
+ }
152
+ NEW_QUIZ_RESULT_VIEW_SCHEMA = {
153
+ "result_view_restricted": {
154
+ "default": False,
155
+ "description": "Whether to restrict what students see in results",
156
+ },
157
+ "display_points_awarded": {
158
+ "default": True,
159
+ "description": "Show points earned (requires result_view_restricted=True)",
160
+ },
161
+ "display_points_possible": {
162
+ "default": True,
163
+ "description": "Show total points possible (requires result_view_restricted=True)",
164
+ },
165
+ "display_items": {
166
+ "default": True,
167
+ "description": "Show questions in results (requires result_view_restricted=True)",
168
+ },
169
+ "display_item_response": {
170
+ "default": True,
171
+ "description": "Show student responses (requires display_items=True)",
172
+ },
173
+ "display_item_response_qualifier": {
174
+ "default": "always",
175
+ "description": "When to show responses: always, once_per_attempt, after_last_attempt, once_after_last_attempt",
176
+ },
177
+ "display_item_response_correctness": {
178
+ "default": True,
179
+ "description": "Show whether answers are correct/incorrect (requires display_item_response=True)",
180
+ },
181
+ "display_item_response_correctness_qualifier": {
182
+ "default": "always",
183
+ "description": "When to show correctness: always, after_last_attempt",
184
+ },
185
+ "display_item_correct_answer": {
186
+ "default": True,
187
+ "description": "Show the correct answer (requires display_item_response_correctness=True)",
188
+ },
189
+ "display_item_feedback": {
190
+ "default": True,
191
+ "description": "Show item feedback (requires display_items=True)",
192
+ },
193
+ }
126
194
  EXAMPLE_NEW_QUIZ_JSON = {
127
195
  "items": [
128
196
  {
@@ -537,6 +605,176 @@ EXAMPLE_CLASSIC_QUIZ_JSON = {
537
605
  },
538
606
  ]
539
607
  }
608
+ EXAMPLE_FULL_NEW_QUIZ_JSON = {
609
+ "quiz_type": "new",
610
+ "settings": {
611
+ "title": "Example Practice Quiz",
612
+ "instructions": "<p>This is a practice quiz to test your knowledge. "
613
+ "You can retry multiple times with a 1-hour waiting period "
614
+ "between attempts. Your latest score will be kept.</p>"
615
+ "<p>You will see your score but not the correct answers, "
616
+ "so you can keep practicing until you get them all right!</p>",
617
+ "time_limit": 1800,
618
+ "points_possible": 20,
619
+ "due_at": None,
620
+ "unlock_at": None,
621
+ "lock_at": None,
622
+ "quiz_settings": {
623
+ # Randomization settings
624
+ "shuffle_answers": True,
625
+ "shuffle_questions": False,
626
+ # Time limit settings
627
+ "has_time_limit": True,
628
+ "session_time_limit_in_seconds": 1800,
629
+ # Question display settings
630
+ "one_at_a_time_type": "none",
631
+ "allow_backtracking": True,
632
+ # Calculator settings
633
+ "calculator_type": "none",
634
+ # Access restrictions
635
+ "filter_ip_address": False,
636
+ "filters": {},
637
+ "require_student_access_code": False,
638
+ "student_access_code": None,
639
+ # Multiple attempts settings
640
+ "multiple_attempts": {
641
+ "multiple_attempts_enabled": True,
642
+ "attempt_limit": False,
643
+ "max_attempts": None,
644
+ "score_to_keep": "latest",
645
+ "cooling_period": True,
646
+ "cooling_period_seconds": 3600,
647
+ },
648
+ # Result view settings - what students see after submission
649
+ "result_view_settings": {
650
+ "result_view_restricted": True,
651
+ "display_points_awarded": True,
652
+ "display_points_possible": True,
653
+ "display_items": True,
654
+ "display_item_response": True,
655
+ "display_item_response_qualifier": "always",
656
+ "display_item_response_correctness": True,
657
+ "display_item_correct_answer": False,
658
+ "display_item_feedback": False,
659
+ "display_correct_answer_at": None,
660
+ "hide_correct_answer_at": None,
661
+ },
662
+ },
663
+ },
664
+ "items": [
665
+ {
666
+ "position": 1,
667
+ "points_possible": 5,
668
+ "entry": {
669
+ "title": "Geography: Capital Cities",
670
+ "item_body": "<p>What is the capital of Sweden?</p>",
671
+ "interaction_type_slug": "choice",
672
+ "scoring_algorithm": "Equivalence",
673
+ "interaction_data": {
674
+ "choices": [
675
+ {"position": 1, "item_body": "<p>Stockholm</p>"},
676
+ {"position": 2, "item_body": "<p>Gothenburg</p>"},
677
+ {"position": 3, "item_body": "<p>Malmö</p>"},
678
+ {"position": 4, "item_body": "<p>Uppsala</p>"},
679
+ ]
680
+ },
681
+ "scoring_data": {"value": 1},
682
+ },
683
+ },
684
+ {
685
+ "position": 2,
686
+ "points_possible": 5,
687
+ "entry": {
688
+ "title": "Programming: Language Type",
689
+ "item_body": "<p>Python is an interpreted programming language.</p>",
690
+ "interaction_type_slug": "true-false",
691
+ "scoring_algorithm": "Equivalence",
692
+ "interaction_data": {"true_choice": "True", "false_choice": "False"},
693
+ "scoring_data": {"value": True},
694
+ },
695
+ },
696
+ {
697
+ "position": 3,
698
+ "points_possible": 5,
699
+ "entry": {
700
+ "title": "Math: Select All Correct",
701
+ "item_body": "<p>Which of the following are prime numbers?</p>",
702
+ "interaction_type_slug": "multi-answer",
703
+ "scoring_algorithm": "AllOrNothing",
704
+ "interaction_data": {
705
+ "choices": [
706
+ {"position": 1, "item_body": "<p>2</p>"},
707
+ {"position": 2, "item_body": "<p>4</p>"},
708
+ {"position": 3, "item_body": "<p>7</p>"},
709
+ {"position": 4, "item_body": "<p>9</p>"},
710
+ {"position": 5, "item_body": "<p>11</p>"},
711
+ ]
712
+ },
713
+ "scoring_data": {"value": [1, 3, 5]},
714
+ },
715
+ },
716
+ {
717
+ "position": 4,
718
+ "points_possible": 5,
719
+ "entry": {
720
+ "title": "Programming: Output Question",
721
+ "item_body": "<p>What does the following Python code print?</p>"
722
+ "<pre>x = 5\nif x > 3:\n print('big')\nelse:\n print('small')</pre>",
723
+ "interaction_type_slug": "choice",
724
+ "scoring_algorithm": "Equivalence",
725
+ "interaction_data": {
726
+ "choices": [
727
+ {"position": 1, "item_body": "<p>big</p>"},
728
+ {"position": 2, "item_body": "<p>small</p>"},
729
+ {"position": 3, "item_body": "<p>5</p>"},
730
+ {"position": 4, "item_body": "<p>Nothing is printed</p>"},
731
+ ]
732
+ },
733
+ "scoring_data": {"value": 1},
734
+ },
735
+ },
736
+ ],
737
+ }
738
+ EXAMPLE_FULL_CLASSIC_QUIZ_JSON = {
739
+ "quiz_type": "classic",
740
+ "settings": {
741
+ "title": "Example Classic Quiz",
742
+ "description": "<p>Answer all questions carefully. Time limit: 60 minutes.</p>",
743
+ "quiz_type": "assignment",
744
+ "time_limit": 60,
745
+ "allowed_attempts": 2,
746
+ "shuffle_questions": True,
747
+ "shuffle_answers": True,
748
+ "points_possible": 100,
749
+ "published": False,
750
+ "due_at": None,
751
+ "unlock_at": None,
752
+ "lock_at": None,
753
+ },
754
+ "questions": [
755
+ {
756
+ "question_name": "Capital Question",
757
+ "question_text": "<p>What is the capital of Sweden?</p>",
758
+ "question_type": "multiple_choice_question",
759
+ "points_possible": 5,
760
+ "answers": [
761
+ {"answer_text": "Stockholm", "answer_weight": 100},
762
+ {"answer_text": "Gothenburg", "answer_weight": 0},
763
+ {"answer_text": "Malmö", "answer_weight": 0},
764
+ ],
765
+ },
766
+ {
767
+ "question_name": "True/False Question",
768
+ "question_text": "<p>Python is an interpreted language.</p>",
769
+ "question_type": "true_false_question",
770
+ "points_possible": 5,
771
+ "answers": [
772
+ {"answer_text": "True", "answer_weight": 100},
773
+ {"answer_text": "False", "answer_weight": 0},
774
+ ],
775
+ },
776
+ ],
777
+ }
540
778
  import logging
541
779
  from datetime import datetime, timedelta
542
780
 
@@ -1816,23 +2054,50 @@ def generate_latex_postamble():
1816
2054
 
1817
2055
  def create_command(config, canvas, args):
1818
2056
  """Creates a new quiz in a course"""
2057
+ # Handle --example flag first (doesn't require course/file)
2058
+ if getattr(args, "example", False):
2059
+ print_full_quiz_example_json()
2060
+ return
2061
+
2062
+ # Validate required arguments when not using --example
2063
+ if not getattr(args, "course", None):
2064
+ canvaslms.cli.err(1, "Please specify -c/--course or use --example")
2065
+ if not getattr(args, "file", None) and not getattr(args, "title", None):
2066
+ canvaslms.cli.err(1, "Please specify -f/--file or --title or use --example")
2067
+
1819
2068
  # Get the course
1820
2069
  course_list = courses.process_course_option(canvas, args)
1821
2070
  if len(course_list) != 1:
1822
2071
  canvaslms.cli.err(1, "Please specify exactly one course for quiz creation")
1823
2072
  course = course_list[0]
1824
2073
 
1825
- # Read quiz settings from file or use defaults
1826
- quiz_params = {}
2074
+ # Read quiz data from file or use defaults
2075
+ quiz_data = {}
1827
2076
  if args.file:
1828
2077
  try:
1829
2078
  with open(args.file, "r", encoding="utf-8") as f:
1830
- quiz_params = json.load(f)
2079
+ quiz_data = json.load(f)
1831
2080
  except FileNotFoundError:
1832
2081
  canvaslms.cli.err(1, f"File not found: {args.file}")
1833
2082
  except json.JSONDecodeError as e:
1834
2083
  canvaslms.cli.err(1, f"Invalid JSON in {args.file}: {e}")
1835
2084
 
2085
+ # Determine quiz type from args or JSON
2086
+ quiz_type = args.type
2087
+ if quiz_type is None:
2088
+ quiz_type = quiz_data.get("quiz_type", "new")
2089
+
2090
+ # Extract settings: support both full format (with 'settings' key) and simple format
2091
+ if "settings" in quiz_data:
2092
+ quiz_params = quiz_data["settings"].copy()
2093
+ else:
2094
+ # Simple format: entire JSON is settings (excluding items/questions)
2095
+ quiz_params = {
2096
+ k: v
2097
+ for k, v in quiz_data.items()
2098
+ if k not in ("quiz_type", "items", "questions")
2099
+ }
2100
+
1836
2101
  # Command-line title overrides file
1837
2102
  if args.title:
1838
2103
  quiz_params["title"] = args.title
@@ -1841,18 +2106,36 @@ def create_command(config, canvas, args):
1841
2106
  canvaslms.cli.err(1, "Quiz title is required (use --title or include in JSON)")
1842
2107
 
1843
2108
  # Create the quiz
1844
- if args.type == "new":
1845
- quiz = create_new_quiz(course, canvas._Canvas__requester, quiz_params)
2109
+ requester = canvas._Canvas__requester
2110
+ if quiz_type == "new":
2111
+ quiz = create_new_quiz(course, requester, quiz_params)
1846
2112
  else:
1847
2113
  quiz = create_classic_quiz(course, quiz_params)
1848
2114
 
1849
- if quiz:
1850
- print(
1851
- f"Created quiz: {quiz_params.get('title')} (ID: {quiz.get('id', 'unknown')})"
1852
- )
1853
- else:
2115
+ if not quiz:
1854
2116
  canvaslms.cli.err(1, "Failed to create quiz")
1855
2117
 
2118
+ quiz_id = quiz.get("id", "unknown")
2119
+ print(f"Created quiz: {quiz_params.get('title')} (ID: {quiz_id})")
2120
+
2121
+ # Add questions if present in JSON
2122
+ items = quiz_data.get("items", [])
2123
+ questions = quiz_data.get("questions", [])
2124
+
2125
+ if quiz_type == "new" and items:
2126
+ print(f"Adding {len(items)} question(s)...")
2127
+ success, failed = add_new_quiz_items(course, quiz_id, requester, items)
2128
+ print(f"Added {success} question(s), {failed} failed")
2129
+ elif quiz_type == "classic" and questions:
2130
+ # For classic quizzes, we need to get the quiz object to add questions
2131
+ try:
2132
+ quiz_obj = course.get_quiz(quiz_id)
2133
+ print(f"Adding {len(questions)} question(s)...")
2134
+ success, failed = add_classic_questions(quiz_obj, questions)
2135
+ print(f"Added {success} question(s), {failed} failed")
2136
+ except Exception as e:
2137
+ canvaslms.cli.warn(f"Failed to add questions: {e}")
2138
+
1856
2139
 
1857
2140
  def create_new_quiz(course, requester, quiz_params):
1858
2141
  """Creates a New Quiz via the New Quizzes API
@@ -1860,17 +2143,15 @@ def create_new_quiz(course, requester, quiz_params):
1860
2143
  Args:
1861
2144
  course: Course object
1862
2145
  requester: Canvas API requester for direct HTTP calls
1863
- quiz_params: Dictionary of quiz parameters
2146
+ quiz_params: Dictionary of quiz parameters, may include nested quiz_settings
1864
2147
 
1865
2148
  Returns:
1866
2149
  Dictionary with created quiz data, or None on failure
1867
2150
  """
1868
2151
  endpoint = f"courses/{course.id}/quizzes"
1869
2152
 
1870
- # Build the request parameters
1871
- params = {}
1872
- for key, value in quiz_params.items():
1873
- params[f"quiz[{key}]"] = value
2153
+ # Build the request parameters, handling nested quiz_settings
2154
+ params = build_new_quiz_api_params(quiz_params)
1874
2155
 
1875
2156
  try:
1876
2157
  response = requester.request(
@@ -1882,6 +2163,50 @@ def create_new_quiz(course, requester, quiz_params):
1882
2163
  return None
1883
2164
 
1884
2165
 
2166
+ def build_new_quiz_api_params(quiz_params):
2167
+ """Converts quiz parameters to Canvas API format
2168
+
2169
+ Handles nested structures like quiz_settings.multiple_attempts by
2170
+ flattening them into the format:
2171
+ quiz[quiz_settings][multiple_attempts][key]=value
2172
+
2173
+ Args:
2174
+ quiz_params: Dictionary with quiz parameters, may include nested dicts
2175
+
2176
+ Returns:
2177
+ Dictionary suitable for passing to requester.request()
2178
+ """
2179
+ params = {}
2180
+
2181
+ for key, value in quiz_params.items():
2182
+ if value is None:
2183
+ continue
2184
+
2185
+ if key == "quiz_settings" and isinstance(value, dict):
2186
+ # Handle nested quiz_settings structure
2187
+ for settings_key, settings_value in value.items():
2188
+ if settings_value is None:
2189
+ continue
2190
+
2191
+ if isinstance(settings_value, dict):
2192
+ # Handle doubly-nested structures like multiple_attempts, result_view_settings
2193
+ for nested_key, nested_value in settings_value.items():
2194
+ if nested_value is not None:
2195
+ param_key = (
2196
+ f"quiz[quiz_settings][{settings_key}][{nested_key}]"
2197
+ )
2198
+ params[param_key] = nested_value
2199
+ else:
2200
+ # Direct quiz_settings value (e.g., shuffle_answers)
2201
+ param_key = f"quiz[quiz_settings][{settings_key}]"
2202
+ params[param_key] = settings_value
2203
+ else:
2204
+ # Top-level quiz parameter
2205
+ params[f"quiz[{key}]"] = value
2206
+
2207
+ return params
2208
+
2209
+
1885
2210
  def create_classic_quiz(course, quiz_params):
1886
2211
  """Creates a Classic Quiz using the canvasapi library
1887
2212
 
@@ -1967,7 +2292,7 @@ def edit_quiz_interactive(quiz, requester, html_mode=False):
1967
2292
  'updated', 'skipped', or 'error'
1968
2293
  """
1969
2294
  # Extract current quiz attributes including instructions
1970
- current_attrs = extract_quiz_attributes(quiz)
2295
+ current_attrs = extract_quiz_attributes(quiz, requester)
1971
2296
 
1972
2297
  # Get content from editor - instructions becomes the body
1973
2298
  result = content.get_content_from_editor(
@@ -2005,14 +2330,16 @@ def edit_quiz_interactive(quiz, requester, html_mode=False):
2005
2330
  return "updated" if success else "error"
2006
2331
 
2007
2332
 
2008
- def extract_quiz_attributes(quiz):
2333
+ def extract_quiz_attributes(quiz, requester=None):
2009
2334
  """Extract editable attributes from a quiz object
2010
2335
 
2011
2336
  Args:
2012
2337
  quiz: Quiz object (New Quiz or Classic Quiz)
2338
+ requester: Canvas API requester (needed for New Quiz settings)
2013
2339
 
2014
2340
  Returns:
2015
2341
  Dictionary of attributes matching QUIZ_SCHEMA, plus 'instructions'
2342
+ and 'quiz_settings' (for New Quizzes)
2016
2343
  """
2017
2344
  attrs = {}
2018
2345
 
@@ -2033,12 +2360,39 @@ def extract_quiz_attributes(quiz):
2033
2360
  # Add instructions (not in schema, but needed for content_attr)
2034
2361
  if is_new_quiz(quiz):
2035
2362
  attrs["instructions"] = getattr(quiz, "instructions", "") or ""
2363
+ # Fetch quiz_settings for New Quizzes
2364
+ if requester:
2365
+ quiz_settings = fetch_new_quiz_settings(quiz, requester)
2366
+ if quiz_settings:
2367
+ attrs["quiz_settings"] = quiz_settings
2036
2368
  else:
2037
2369
  attrs["instructions"] = getattr(quiz, "description", "") or ""
2038
2370
 
2039
2371
  return attrs
2040
2372
 
2041
2373
 
2374
+ def fetch_new_quiz_settings(quiz, requester):
2375
+ """Fetch quiz_settings from the New Quizzes API
2376
+
2377
+ Args:
2378
+ quiz: Quiz object (must have .id and .course attributes)
2379
+ requester: Canvas API requester
2380
+
2381
+ Returns:
2382
+ Dictionary with quiz_settings, or None if unavailable
2383
+ """
2384
+ try:
2385
+ endpoint = f"courses/{quiz.course.id}/quizzes/{quiz.id}"
2386
+ response = requester.request(
2387
+ method="GET", endpoint=endpoint, _url="new_quizzes"
2388
+ )
2389
+ data = response.json()
2390
+ return data.get("quiz_settings", None)
2391
+ except Exception as e:
2392
+ canvaslms.cli.warn(f"Failed to fetch New Quiz settings: {e}")
2393
+ return None
2394
+
2395
+
2042
2396
  def apply_quiz_edit(quiz, attributes, body, requester, html_mode=False):
2043
2397
  """Apply edited attributes and body to a quiz
2044
2398
 
@@ -2082,7 +2436,7 @@ def quiz_attributes_to_api_params(attributes, is_new, html_body):
2082
2436
  html_body: HTML content for instructions/description
2083
2437
 
2084
2438
  Returns:
2085
- Dictionary suitable for Canvas API
2439
+ Dictionary suitable for Canvas API (nested for New Quizzes)
2086
2440
  """
2087
2441
  params = {}
2088
2442
 
@@ -2111,11 +2465,19 @@ def quiz_attributes_to_api_params(attributes, is_new, html_body):
2111
2465
  continue
2112
2466
 
2113
2467
  # Skip hide_results for New Quizzes: result visibility is controlled
2114
- # differently in the New Quizzes interface (through the "Restrict student
2115
- # result view" setting), not via this API parameter.
2468
+ # through quiz_settings.result_view_settings, not this parameter.
2116
2469
  if key == "hide_results" and is_new:
2117
2470
  continue
2118
2471
 
2472
+ # Pass through quiz_settings as-is for New Quizzes
2473
+ if key == "quiz_settings" and is_new:
2474
+ params["quiz_settings"] = value
2475
+ continue
2476
+
2477
+ # Skip instructions - handled separately as body
2478
+ if key == "instructions":
2479
+ continue
2480
+
2119
2481
  params[key] = value
2120
2482
 
2121
2483
  # Add body with appropriate field name (include even if empty to allow clearing)
@@ -2134,16 +2496,15 @@ def update_new_quiz(course, assignment_id, requester, quiz_params):
2134
2496
  course: Course object
2135
2497
  assignment_id: The quiz/assignment ID
2136
2498
  requester: Canvas API requester
2137
- quiz_params: Dictionary of parameters to update
2499
+ quiz_params: Dictionary of parameters to update, may include nested quiz_settings
2138
2500
 
2139
2501
  Returns:
2140
2502
  True on success, False on failure
2141
2503
  """
2142
2504
  endpoint = f"courses/{course.id}/quizzes/{assignment_id}"
2143
2505
 
2144
- params = {}
2145
- for key, value in quiz_params.items():
2146
- params[f"quiz[{key}]"] = value
2506
+ # Build the request parameters, handling nested quiz_settings
2507
+ params = build_new_quiz_api_params(quiz_params)
2147
2508
 
2148
2509
  try:
2149
2510
  requester.request(
@@ -2252,6 +2613,114 @@ def delete_classic_quiz(quiz):
2252
2613
  return False
2253
2614
 
2254
2615
 
2616
+ def export_command(config, canvas, args):
2617
+ """Exports a complete quiz (settings + questions) to JSON"""
2618
+ # Find the quiz
2619
+ course_list = courses.process_course_option(canvas, args)
2620
+ quiz_list = list(filter_quizzes(course_list, args.assignment))
2621
+
2622
+ if not quiz_list:
2623
+ canvaslms.cli.err(1, f"No quiz found matching: {args.assignment}")
2624
+
2625
+ quiz = quiz_list[0]
2626
+ requester = canvas._Canvas__requester
2627
+ include_banks = args.include_banks and not args.no_banks
2628
+ importable = getattr(args, "importable", False)
2629
+
2630
+ # Build the export structure
2631
+ if is_new_quiz(quiz):
2632
+ export = export_full_new_quiz(quiz, requester, include_banks, importable)
2633
+ else:
2634
+ export = export_full_classic_quiz(quiz, importable)
2635
+
2636
+ # Output as JSON
2637
+ print(json.dumps(export, indent=2, ensure_ascii=False))
2638
+
2639
+
2640
+ def export_full_new_quiz(quiz, requester, include_banks=True, importable=False):
2641
+ """Exports a complete New Quiz with settings and items
2642
+
2643
+ Args:
2644
+ quiz: Quiz object (must have .id and .course attributes)
2645
+ requester: Canvas API requester
2646
+ include_banks: If True, expand Bank/BankEntry items to include bank questions
2647
+ importable: If True, clean output for direct import
2648
+
2649
+ Returns:
2650
+ Dictionary with quiz_type, settings (including quiz_settings), and items
2651
+ """
2652
+ # Extract basic settings
2653
+ settings = {
2654
+ "title": getattr(quiz, "title", ""),
2655
+ "instructions": getattr(quiz, "instructions", "") or "",
2656
+ "time_limit": getattr(quiz, "time_limit", None),
2657
+ "points_possible": getattr(quiz, "points_possible", None),
2658
+ "due_at": getattr(quiz, "due_at", None),
2659
+ "unlock_at": getattr(quiz, "unlock_at", None),
2660
+ "lock_at": getattr(quiz, "lock_at", None),
2661
+ }
2662
+
2663
+ # Fetch quiz_settings from the API (contains multiple_attempts, result_view_settings, etc.)
2664
+ quiz_settings = fetch_new_quiz_settings(quiz, requester)
2665
+ if quiz_settings:
2666
+ settings["quiz_settings"] = quiz_settings
2667
+
2668
+ # Get items
2669
+ items_export = export_new_quiz_items(quiz, requester, include_banks=include_banks)
2670
+ items = items_export.get("items", [])
2671
+
2672
+ # Clean for import if requested
2673
+ if importable:
2674
+ items_cleaned = clean_for_import({"items": items}, quiz_type="new_quiz")
2675
+ items = items_cleaned.get("items", [])
2676
+
2677
+ return {"quiz_type": "new", "settings": settings, "items": items}
2678
+
2679
+
2680
+ def export_full_classic_quiz(quiz, importable=False):
2681
+ """Exports a complete Classic Quiz with settings and questions
2682
+
2683
+ Args:
2684
+ quiz: Quiz object
2685
+ importable: If True, clean output for direct import
2686
+
2687
+ Returns:
2688
+ Dictionary with quiz_type, settings, and questions
2689
+ """
2690
+ # Extract settings
2691
+ settings = {
2692
+ "title": getattr(quiz, "title", ""),
2693
+ "description": getattr(quiz, "description", "") or "",
2694
+ "quiz_type": getattr(quiz, "quiz_type", "assignment"),
2695
+ "time_limit": getattr(quiz, "time_limit", None),
2696
+ "allowed_attempts": getattr(quiz, "allowed_attempts", 1),
2697
+ "shuffle_questions": getattr(quiz, "shuffle_questions", False),
2698
+ "shuffle_answers": getattr(quiz, "shuffle_answers", False),
2699
+ "points_possible": getattr(quiz, "points_possible", None),
2700
+ "published": getattr(quiz, "published", False),
2701
+ "due_at": getattr(quiz, "due_at", None),
2702
+ "unlock_at": getattr(quiz, "unlock_at", None),
2703
+ "lock_at": getattr(quiz, "lock_at", None),
2704
+ "show_correct_answers": getattr(quiz, "show_correct_answers", True),
2705
+ "one_question_at_a_time": getattr(quiz, "one_question_at_a_time", False),
2706
+ "cant_go_back": getattr(quiz, "cant_go_back", False),
2707
+ "access_code": getattr(quiz, "access_code", None),
2708
+ }
2709
+
2710
+ # Get questions
2711
+ questions_export = export_classic_questions(quiz)
2712
+ questions = questions_export.get("questions", [])
2713
+
2714
+ # Clean for import if requested
2715
+ if importable:
2716
+ questions_cleaned = clean_for_import(
2717
+ {"questions": questions}, quiz_type="classic"
2718
+ )
2719
+ questions = questions_cleaned.get("questions", [])
2720
+
2721
+ return {"quiz_type": "classic", "settings": settings, "questions": questions}
2722
+
2723
+
2255
2724
  def add_view_command(subp):
2256
2725
  """Adds the view subcommand to the quizzes command group"""
2257
2726
  view_parser = subp.add_parser(
@@ -3093,6 +3562,94 @@ def print_example_json():
3093
3562
  print(json.dumps(EXAMPLE_CLASSIC_QUIZ_JSON, indent=2))
3094
3563
 
3095
3564
 
3565
+ def print_full_quiz_example_json():
3566
+ """Prints example JSON for full quiz creation (settings + questions)"""
3567
+ print("=" * 70)
3568
+ print("EXAMPLE JSON FOR CREATING NEW QUIZZES (Quizzes.Next)")
3569
+ print("=" * 70)
3570
+ print()
3571
+ print("This format includes both quiz settings and questions.")
3572
+ print("Save to a file and use with:")
3573
+ print(" canvaslms quizzes create -c COURSE -f quiz.json")
3574
+ print()
3575
+ print("This is the same format produced by 'quizzes export -I'.")
3576
+ print()
3577
+ print("BASIC SETTINGS:")
3578
+ print(" title - Quiz title")
3579
+ print(" instructions - HTML instructions shown to students")
3580
+ print(" time_limit - Time limit in SECONDS (or null)")
3581
+ print(" points_possible - Total points")
3582
+ print(" due_at/unlock_at/lock_at - ISO 8601 dates (or null)")
3583
+ print()
3584
+ print("QUIZ SETTINGS (in 'settings.quiz_settings'):")
3585
+ print()
3586
+ print(" Randomization:")
3587
+ print(" shuffle_answers: true/false - Randomize answer order")
3588
+ print(" shuffle_questions: true/false - Randomize question order")
3589
+ print()
3590
+ print(" Time limit:")
3591
+ print(" has_time_limit: true/false")
3592
+ print(" session_time_limit_in_seconds: number")
3593
+ print()
3594
+ print(" Question display:")
3595
+ print(" one_at_a_time_type: 'none' or 'question'")
3596
+ print(" allow_backtracking: true/false - Can go back to previous questions")
3597
+ print()
3598
+ print(" Calculator:")
3599
+ print(" calculator_type: 'none', 'basic', or 'scientific'")
3600
+ print()
3601
+ print(" Access restrictions:")
3602
+ print(" require_student_access_code: true/false")
3603
+ print(" student_access_code: 'password' or null")
3604
+ print(" filter_ip_address: true/false")
3605
+ print(" filters: {} or IP filter rules")
3606
+ print()
3607
+ print(" Multiple attempts:")
3608
+ print(" multiple_attempts_enabled: true/false")
3609
+ print(" attempt_limit: true/false (true = limited, false = unlimited)")
3610
+ print(" max_attempts: number or null")
3611
+ print(" score_to_keep: 'highest' or 'latest'")
3612
+ print(" cooling_period: true/false (require wait between attempts)")
3613
+ print(" cooling_period_seconds: seconds (e.g., 3600 = 1 hour)")
3614
+ print()
3615
+ print(" Result view (what students see after submission):")
3616
+ print(" result_view_restricted: true/false")
3617
+ print(" display_items: true/false - Show questions")
3618
+ print(" display_item_response: true/false - Show student's answers")
3619
+ print(" display_item_response_correctness: true/false - Show right/wrong")
3620
+ print(" display_item_correct_answer: true/false - Show correct answers")
3621
+ print(" display_item_feedback: true/false - Show per-question feedback")
3622
+ print(" display_points_awarded: true/false - Show points earned")
3623
+ print(" display_points_possible: true/false - Show max points")
3624
+ print(" display_correct_answer_at: ISO date or null - When to reveal")
3625
+ print(" hide_correct_answer_at: ISO date or null - When to hide")
3626
+ print()
3627
+ print("SCORING:")
3628
+ print(" Use position numbers (1, 2, 3...) to reference correct answers.")
3629
+ print(" UUIDs are generated automatically during import.")
3630
+ print()
3631
+ print(json.dumps(EXAMPLE_FULL_NEW_QUIZ_JSON, indent=2))
3632
+ print()
3633
+ print()
3634
+ print("=" * 70)
3635
+ print("EXAMPLE JSON FOR CREATING CLASSIC QUIZZES")
3636
+ print("=" * 70)
3637
+ print()
3638
+ print("Classic Quizzes use different field names and units.")
3639
+ print()
3640
+ print("Settings (time_limit in MINUTES for Classic Quizzes):")
3641
+ print(" title, description (not instructions), quiz_type,")
3642
+ print(" time_limit, allowed_attempts, shuffle_questions,")
3643
+ print(" shuffle_answers, points_possible, published,")
3644
+ print(" due_at, unlock_at, lock_at, show_correct_answers,")
3645
+ print(" one_question_at_a_time, cant_go_back, access_code")
3646
+ print()
3647
+ print("quiz_type values: assignment, practice_quiz, graded_survey, survey")
3648
+ print("answer_weight: 100 = correct, 0 = incorrect")
3649
+ print()
3650
+ print(json.dumps(EXAMPLE_FULL_CLASSIC_QUIZ_JSON, indent=2))
3651
+
3652
+
3096
3653
  def items_add_command(config, canvas, args):
3097
3654
  """Adds questions to a quiz from a JSON file"""
3098
3655
  # Handle --example flag first (doesn't require course/assignment/file)
@@ -3621,15 +4178,36 @@ def clean_interaction_data(interaction_data):
3621
4178
  clean = dict(interaction_data)
3622
4179
 
3623
4180
  # Handle choices array (multiple choice, multi-answer)
4181
+ # Choices are dicts with id, position, item_body
3624
4182
  if "choices" in clean:
3625
4183
  clean_choices = []
3626
4184
  for i, choice in enumerate(clean["choices"]):
4185
+ # Skip if choice is not a dict (shouldn't happen, but be safe)
4186
+ if not isinstance(choice, dict):
4187
+ clean_choices.append(choice)
4188
+ continue
3627
4189
  clean_choice = {"position": choice.get("position", i + 1)}
3628
4190
  if "item_body" in choice:
3629
4191
  clean_choice["item_body"] = choice["item_body"]
3630
4192
  clean_choices.append(clean_choice)
3631
4193
  clean["choices"] = clean_choices
3632
4194
 
4195
+ # Handle questions array (matching questions)
4196
+ # Questions are dicts with id, item_body - we keep item_body, drop id
4197
+ if "questions" in clean:
4198
+ clean_questions = []
4199
+ for i, question in enumerate(clean["questions"]):
4200
+ if not isinstance(question, dict):
4201
+ clean_questions.append(question)
4202
+ continue
4203
+ clean_q = {}
4204
+ if "item_body" in question:
4205
+ clean_q["item_body"] = question["item_body"]
4206
+ clean_questions.append(clean_q)
4207
+ clean["questions"] = clean_questions
4208
+
4209
+ # 'answers' is a list of strings (matching questions) - keep as-is
4210
+
3633
4211
  return clean
3634
4212
 
3635
4213
 
@@ -4047,6 +4625,7 @@ def add_command(subp):
4047
4625
  add_create_command(quizzes_subp)
4048
4626
  add_edit_command(quizzes_subp)
4049
4627
  add_delete_command(quizzes_subp)
4628
+ add_export_command(quizzes_subp)
4050
4629
  add_items_command(quizzes_subp)
4051
4630
  add_banks_command(quizzes_subp)
4052
4631
 
@@ -4163,31 +4742,70 @@ def add_create_command(subp):
4163
4742
  create_parser = subp.add_parser(
4164
4743
  "create",
4165
4744
  help="Create a new quiz",
4166
- description="""Create a new quiz in a course. Quiz settings can be
4167
- provided via a JSON file or entered interactively.""",
4745
+ description="""Create a new quiz in a course from a JSON file.
4746
+
4747
+ Use --example to see the full JSON format with all supported attributes.
4748
+ The JSON can include both quiz settings and questions, enabling a complete
4749
+ export/create workflow:
4750
+
4751
+ canvaslms quizzes export -c "Source Course" -a "Quiz" -I > quiz.json
4752
+ canvaslms quizzes create -c "Target Course" -f quiz.json
4753
+
4754
+ JSON STRUCTURE:
4755
+ {
4756
+ "quiz_type": "new" or "classic",
4757
+ "settings": { ... quiz settings ... },
4758
+ "items": [ ... ] (New Quizzes) or "questions": [ ... ] (Classic)
4759
+ }
4760
+
4761
+ SETTINGS FOR NEW QUIZZES (time_limit in seconds):
4762
+ title, instructions, time_limit, allowed_attempts, shuffle_questions,
4763
+ shuffle_answers, points_possible, due_at, unlock_at, lock_at
4764
+
4765
+ ADVANCED SETTINGS FOR NEW QUIZZES (in settings.quiz_settings):
4766
+ multiple_attempts: attempt_limit, score_to_keep, cooling_period_seconds
4767
+ result_view_settings: display_item_correct_answer, display_item_feedback, etc.
4768
+
4769
+ SETTINGS FOR CLASSIC QUIZZES (time_limit in minutes):
4770
+ title, description, quiz_type (assignment/practice_quiz/graded_survey/survey),
4771
+ time_limit, allowed_attempts, shuffle_questions, shuffle_answers,
4772
+ points_possible, published, due_at, unlock_at, lock_at,
4773
+ show_correct_answers, one_question_at_a_time, cant_go_back, access_code
4774
+
4775
+ For question format details, see: canvaslms quizzes items add --example""",
4168
4776
  )
4169
4777
 
4170
4778
  create_parser.set_defaults(func=create_command)
4171
4779
 
4172
4780
  try:
4173
- courses.add_course_option(create_parser, required=True)
4781
+ courses.add_course_option(create_parser, required=False)
4174
4782
  except argparse.ArgumentError:
4175
4783
  pass
4176
4784
 
4177
4785
  create_parser.add_argument(
4178
- "-f", "--file", help="JSON file containing quiz settings", type=str
4786
+ "-f",
4787
+ "--file",
4788
+ help="JSON file containing quiz settings and optionally questions",
4789
+ type=str,
4179
4790
  )
4180
4791
 
4181
4792
  create_parser.add_argument(
4182
4793
  "--type",
4183
4794
  choices=["new", "classic"],
4184
- default="new",
4795
+ default=None,
4185
4796
  help="Quiz type: 'new' (New Quizzes) or 'classic' (Classic Quizzes). "
4186
- "Default: new",
4797
+ "Auto-detected from JSON if not specified. Default: new",
4187
4798
  )
4188
4799
 
4189
4800
  create_parser.add_argument(
4190
- "--title", "-t", help="Quiz title (can also be specified in JSON file)"
4801
+ "--title", "-t", help="Quiz title (overrides title in JSON file)"
4802
+ )
4803
+
4804
+ create_parser.add_argument(
4805
+ "--example",
4806
+ "-E",
4807
+ action="store_true",
4808
+ help="Print example JSON for creating quizzes and exit",
4191
4809
  )
4192
4810
 
4193
4811
 
@@ -4260,6 +4878,68 @@ def add_delete_command(subp):
4260
4878
  )
4261
4879
 
4262
4880
 
4881
+ def add_export_command(subp):
4882
+ """Adds the quizzes export subcommand to argparse parser subp"""
4883
+ export_parser = subp.add_parser(
4884
+ "export",
4885
+ help="Export a complete quiz to JSON",
4886
+ description="""Export a quiz (settings and questions) to JSON format.
4887
+
4888
+ The output can be directly used with 'quizzes create' to duplicate a quiz
4889
+ in another course or create a backup.
4890
+
4891
+ WORKFLOW EXAMPLE:
4892
+ # Export quiz from source course
4893
+ canvaslms quizzes export -c "Course A" -a "Quiz Name" -I > quiz.json
4894
+
4895
+ # Create identical quiz in target course
4896
+ canvaslms quizzes create -c "Course B" -f quiz.json
4897
+
4898
+ OUTPUT FORMAT:
4899
+ {
4900
+ "quiz_type": "new" or "classic",
4901
+ "settings": { ... quiz settings ... },
4902
+ "items": [ ... ] (New Quizzes) or "questions": [ ... ] (Classic)
4903
+ }
4904
+
4905
+ Use --importable/-I for clean JSON ready for 'quizzes create'.
4906
+ Without -I, the output includes Canvas IDs and metadata for reference.""",
4907
+ )
4908
+
4909
+ export_parser.set_defaults(func=export_command)
4910
+
4911
+ try:
4912
+ courses.add_course_option(export_parser, required=True)
4913
+ except argparse.ArgumentError:
4914
+ pass
4915
+
4916
+ export_parser.add_argument(
4917
+ "-a",
4918
+ "--assignment",
4919
+ required=True,
4920
+ help="Regex matching quiz title or Canvas ID",
4921
+ )
4922
+
4923
+ export_parser.add_argument(
4924
+ "--importable",
4925
+ "-I",
4926
+ action="store_true",
4927
+ help="Output clean JSON directly usable with 'quizzes create' command",
4928
+ )
4929
+
4930
+ export_parser.add_argument(
4931
+ "--include-banks",
4932
+ "-B",
4933
+ action="store_true",
4934
+ default=True,
4935
+ help="Include questions from referenced item banks (default: true)",
4936
+ )
4937
+
4938
+ export_parser.add_argument(
4939
+ "--no-banks", action="store_true", help="Don't expand item bank references"
4940
+ )
4941
+
4942
+
4263
4943
  def add_items_command(subp):
4264
4944
  """Adds the quizzes items subcommand group to argparse parser subp"""
4265
4945
  items_parser = subp.add_parser(