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/modules.nw +49 -7
- canvaslms/cli/modules.py +24 -6
- canvaslms/cli/pages.nw +5 -0
- canvaslms/cli/quizzes.nw +1042 -67
- canvaslms/cli/quizzes.py +711 -31
- {canvaslms-5.2.dist-info → canvaslms-5.4.dist-info}/METADATA +1 -1
- {canvaslms-5.2.dist-info → canvaslms-5.4.dist-info}/RECORD +10 -10
- {canvaslms-5.2.dist-info → canvaslms-5.4.dist-info}/WHEEL +0 -0
- {canvaslms-5.2.dist-info → canvaslms-5.4.dist-info}/entry_points.txt +0 -0
- {canvaslms-5.2.dist-info → canvaslms-5.4.dist-info}/licenses/LICENSE +0 -0
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
|
|
1826
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1845
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
2145
|
-
|
|
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
|
|
4167
|
-
|
|
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=
|
|
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",
|
|
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=
|
|
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 (
|
|
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(
|