quizcomp.model.quiz
1import enum 2import logging 3import os 4import random 5import string 6import typing 7 8import edq.util.dirent 9import edq.util.enum 10import edq.util.git 11import edq.util.time 12 13import quizcomp.model.base 14import quizcomp.model.constants 15import quizcomp.model.errors 16import quizcomp.model.group 17import quizcomp.model.question 18import quizcomp.parser.document 19 20_logger = logging.getLogger(__name__) 21 22DUMMY_QUIZ_DATA: typing.Dict[str, typing.Any] = { 23 'name': 'Dummy Name', 24 'description': quizcomp.parser.document.ParsedDocument.parse_text('Dummy description.'), 25 'course_name': 'Dummy Course', 26 'term_name': 'Dummy Term', 27 'version': '0.0.0', 28 'shuffle_answers': False, 29} 30 31DUMMY_GROUP_DATA: typing.Dict[str, typing.Any] = { 32 'name': 'Dummy Question', 33} 34 35DEFAULT_VARIANT_IDS: typing.List[str] = list(string.ascii_uppercase) 36""" Default IDs for quiz variants. """ 37 38DEFAULT_MAX_VARIANTS: int = len(DEFAULT_VARIANT_IDS) 39 40class HideResultsBehavior(enum.Enum): 41 """ 42 The allowed behaviors for hiding results from students for a quiz with multiple attempts. 43 """ 44 45 ALWASY_HIDE = 'always' 46 """ Students can never see their results. """ 47 48 NEVER_HIDE = 'never' 49 """ Students can see their results after each attempt. """ 50 51 UNTIL_AFTER_LAST_ATTEMPT = 'until_after_last_attempt' 52 """ Students can see their results after each attempt. """ 53 54class ScoringPolicy(enum.Enum): 55 """ 56 The allowed scoring policies for quizzes with multiple attempts. 57 """ 58 59 KEEP_HIGHEST = 'keep_highest' 60 """ Keep the highest score from all attempts. """ 61 62 KEEP_LATEST = 'keep_latest' 63 """ Keep the most recent score from all attempts. """ 64 65class Quiz(quizcomp.model.base.CoreType): 66 """ 67 A quiz object represents multiple possible assessments (called "variants"). 68 """ 69 70 def __init__(self, 71 children: typing.Union[typing.List[quizcomp.model.group.Group], None] = None, 72 description: typing.Union[quizcomp.parser.document.ParsedDocument, str, None] = None, 73 course_name: typing.Union[str, None] = None, 74 term_name: typing.Union[str, None] = None, 75 date: typing.Union[edq.util.time.Timestamp, None] = None, 76 time_limit_mins: typing.Union[int, None] = None, 77 version: typing.Union[str, None] = None, 78 practice: typing.Union[bool, None] = None, 79 publish: typing.Union[bool, None] = None, 80 assignment_group: typing.Union[str, None] = None, 81 allowed_attempts: typing.Union[int, None] = None, 82 show_correct_answers: typing.Union[bool, None] = None, 83 hide_results: typing.Union[HideResultsBehavior, str, None] = None, 84 scoring_policy: typing.Union[ScoringPolicy, str, None] = None, 85 **kwargs: typing.Any) -> None: 86 # Remove aliases before super construction. 87 kwargs.pop('groups', None) 88 89 super().__init__(children = children, **kwargs) 90 91 self.course_name: typing.Union[str, None] = course_name 92 """ The optional name for the course associated with this quiz. """ 93 94 self.term_name: typing.Union[str, None] = term_name 95 """ The optional name of the term this quiz takes place during (e.g., "Fall 20XX"). """ 96 97 self.date: typing.Union[edq.util.time.Timestamp, None] = date 98 """ The optional date of this quiz. """ 99 100 if (description is None): 101 description = quizcomp.parser.document.ParsedDocument() 102 103 if (isinstance(description, str)): 104 description = quizcomp.parser.document.ParsedDocument.parse_text(description) 105 106 self.description: quizcomp.parser.document.ParsedDocument = description 107 """ The description/prompt for this quiz. """ 108 109 if ((time_limit_mins is not None) and (time_limit_mins < 0)): 110 time_limit_mins = None 111 112 self.time_limit_mins: typing.Union[int, None] = time_limit_mins 113 """ The time limit (in minutes) for this quiz. """ 114 115 self.version: typing.Union[str, None] = version 116 """ The version of this quiz. """ 117 118 self.practice: typing.Union[bool, None] = practice 119 """ 120 Whether this quiz should be considered a "practice" quiz. 121 This may change the behavior of different quizzes when uploaded to different platforms. 122 """ 123 124 self.publish: typing.Union[bool, None] = publish 125 """ 126 Whether this quiz should be considered published on upload. 127 "Published" quizzes are typically visible to students after upload. 128 """ 129 130 self.assignment_group: typing.Union[str, None] = assignment_group 131 """ 132 The name of the assignment group that this quiz should be uploaded under. 133 Unnecessary if this quiz is not uploaded. 134 """ 135 136 self.allowed_attempts: typing.Union[int, None] = allowed_attempts 137 """ The number of attempts a student should have when taking this quiz. """ 138 139 self.show_correct_answers: typing.Union[bool, None] = show_correct_answers 140 """ Show students the correct answer after submission. """ 141 142 if (isinstance(hide_results, str)): 143 if (not edq.util.enum.has_value(HideResultsBehavior, hide_results)): 144 _logger.warning("Unknown enum value for 'hide_results': '%s'. Setting to null.", hide_results) 145 hide_results = None 146 else: 147 hide_results = HideResultsBehavior(hide_results) 148 149 self.hide_results: typing.Union[HideResultsBehavior, None] = hide_results 150 """ The behavior for showing results to students when multiple attempts are allowed. """ 151 152 if (isinstance(scoring_policy, str)): 153 if (not edq.util.enum.has_value(ScoringPolicy, scoring_policy)): 154 _logger.warning("Unknown enum value for 'scoring_policy': '%s'. Setting to null.", scoring_policy) 155 scoring_policy = None 156 else: 157 scoring_policy = ScoringPolicy(scoring_policy) 158 159 self.scoring_policy: typing.Union[ScoringPolicy, None] = scoring_policy 160 """ The scoring behavior when multiple attempts are allowed. """ 161 162 self._validate() 163 164 def _validate(self) -> None: 165 """ Check if this quiz is valid. """ 166 167 if (self.name is None): 168 raise quizcomp.model.errors.QuizValidationError("Quiz name cannot be empty.", context = self) 169 170 def get_groups(self) -> typing.List[quizcomp.model.group.Group]: 171 """ Get all groups for this quiz. """ 172 173 return [typing.cast(quizcomp.model.group.Group, child) for child in self.children] 174 175 def collect_documents(self) -> typing.List[quizcomp.parser.document.ParsedDocument]: 176 return [self.description] 177 178 @classmethod 179 def prep_init_data(cls, 180 data: typing.Dict[str, typing.Any], 181 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 182 ) -> typing.Dict[str, typing.Any]: 183 if (context is None): 184 context = edq.util.serial.SerializationContext() 185 186 data = super().prep_init_data(data, context) 187 188 data['description'] = cls._collect_description(data, context) 189 190 return data 191 192 @classmethod 193 def _collect_description(cls, 194 data: typing.Dict[str, typing.Any], 195 context: edq.util.serial.SerializationContext, 196 ) -> quizcomp.parser.document.ParsedDocument: 197 """ 198 Collect the description from one of several possible locations. 199 200 The description is allowed to appear (in order of priority): 201 1) in the `description` field. 202 2) pointed to by the `description_path` field. 203 3) or be in the same path as the quiz JSON, but with an `.md` extension 204 (e.g., `a/b/my_quiz.json` and `a/b/my_quiz.md`). 205 206 None values will be ignored (but empty values are valid). 207 Will return an empty description if none of these are present. 208 """ 209 210 # If we have a quiz path, use that to resolve paths. 211 default_description_path = None 212 if (context.source_path is not None): 213 context.source_path = os.path.abspath(context.source_path) 214 context.base_dir = os.path.dirname(context.source_path) 215 default_description_path = os.path.splitext(context.source_path)[0] + '.md' 216 217 # Check the `description` field. 218 text = data.get('description', None) 219 if (text is not None): 220 return quizcomp.parser.document.ParsedDocument.parse_text(text, context) 221 222 # Check for an explicitly provided path. 223 description_path = data.get('description_path', None) 224 if (description_path is not None): 225 if (not os.path.isabs(description_path)): 226 description_path = os.path.join(context.base_dir, description_path) 227 228 description_path = os.path.abspath(description_path) 229 230 if (not os.path.isfile(description_path)): 231 raise quizcomp.model.errors.QuestionValidationError( 232 f"Could not find a description at the provided path: '{data['description_path']}' (Absolute Path: '{description_path}').", 233 context = context) 234 235 return quizcomp.parser.document.ParsedDocument.parse_file(description_path) 236 237 # Check for an implicit path. 238 if ((default_description_path is not None) and os.path.isfile(default_description_path)): 239 return quizcomp.parser.document.ParsedDocument.parse_file(default_description_path) 240 241 return quizcomp.parser.document.ParsedDocument() 242 243 def to_dict(self, 244 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 245 ) -> typing.Dict[str, edq.util.serial.PODType]: 246 data = super().to_dict(context) 247 data['groups'] = data.pop('children', data.get('groups', None)) 248 return data 249 250 @classmethod 251 def from_dict(cls, 252 data: typing.Dict[str, edq.util.serial.PODType], 253 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 254 ) -> 'Quiz': 255 data['children'] = data.pop('groups', data.get('children', None)) 256 return super().from_dict(data, context) 257 258 def create_variant(self, 259 seed: typing.Union[int, None] = None, 260 identifiers: typing.Union[typing.List[str], None] = None, 261 all_questions: bool = False, 262 include_solo_identifier: bool = False, 263 ) -> 'Variant': 264 """ A convenience call to create_variants(). """ 265 266 return self.create_variants( 267 count = 1, 268 seed = seed, 269 identifiers = identifiers, 270 all_questions = all_questions, include_solo_identifier = include_solo_identifier, 271 )[0] 272 273 def create_variants(self, 274 count: int = 1, 275 seed: typing.Union[int, None] = None, 276 identifiers: typing.Union[typing.List[str], None] = None, 277 all_questions: bool = False, 278 include_solo_identifier: bool = False, 279 ) -> typing.List['Variant']: 280 """ 281 Create a collection of variants based on this quiz. 282 These variants will share the same question pool, 283 which is influenced by the `pick_with_replacement` config option. 284 285 Setting `include_solo_identifier` to true will include an identifier (e.g., " - A") 286 in the name of variants when only one variant is created. 287 """ 288 289 if (seed is None): 290 seed = random.randint(0, 2**64) 291 292 rng = random.Random(seed) 293 294 if (identifiers is None): 295 identifiers = DEFAULT_VARIANT_IDS 296 297 if (count < 0): 298 raise quizcomp.model.errors.QuizValidationError( 299 f"Variant count must be non-negative, found: {count}.", 300 context = self) 301 302 if (count > len(identifiers)): 303 raise quizcomp.model.errors.QuizValidationError( 304 ('"Not enough variant identifiers supplied.' 305 + f" Got {len(identifiers)} identifiers and {count} requested variants." 306 + f" Given identifiers: {identifiers}."), 307 context = self) 308 309 _logger.debug("Creating %d variants with seed %d.", count, seed) 310 311 all_used_question_indexes: typing.List[typing.Set[int]] = [set() for _ in self.children] 312 variants = [] 313 314 for i in range(count): 315 variant_id: typing.Union[str, None] = None 316 if ((count > 1) or (include_solo_identifier)): 317 variant_id = identifiers[i] 318 319 variants.append(self._create_variant(variant_id, rng, all_used_question_indexes, all_questions)) 320 321 return variants 322 323 def _create_variant(self, 324 variant_id: typing.Union[str, None], 325 rng: random.Random, 326 all_used_question_indexes: typing.List[typing.Set[int]], 327 all_questions: bool, 328 ) -> 'Variant': 329 """ Create a single variant based on this quiz. """ 330 331 new_groups = [] 332 for (i, group) in enumerate(self.get_groups()): 333 questions = group.choose_variant_questions(all_questions, all_used_question_indexes[i], rng) 334 335 group_data = vars(group).copy() 336 group_data['children'] = questions 337 338 new_groups.append(quizcomp.model.group.Group(**group_data)) 339 340 data = vars(self).copy() 341 342 data['name'] = self.name 343 if (variant_id is not None): 344 data['name'] += f" - {variant_id}" 345 346 data['variant_id'] = variant_id 347 data['quiz_name'] = self.name 348 data['children'] = new_groups 349 350 data['version'] = self.version 351 if ((self.version is not None) and (variant_id is not None)): 352 data['version'] = f"{self.version}, Variant: {variant_id}" 353 354 return Variant(**data) 355 356 def to_dir(self, 357 base_dir: str, 358 fetch_images: bool = True, 359 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 360 quiz_base_filename: str = 'quiz', 361 **kwargs: typing.Any) -> None: 362 quiz = typing.cast(Quiz, self.copy()) 363 364 quiz.base_dir = os.path.abspath(base_dir) 365 edq.util.dirent.mkdir(quiz.base_dir) 366 367 if (fetch_images): 368 quiz.fetch_and_update_images() 369 370 output_data = quiz.to_dict(context = context) 371 372 # Write the groups in the quiz, but the questions in their own dirs. 373 for (group_index, group) in enumerate(quiz.get_groups()): 374 group_name = f"{group_index:03d} - {group.get_name('Group')}" 375 group_reldir = os.path.join('questions', group_name) 376 377 # Rewrite the group to use question paths. 378 output_data['groups'][group_index]['questions'] = [group_reldir] # type: ignore[index,call-overload] 379 380 # Output each question. 381 for (question_index, question) in enumerate(group.get_questions()): 382 question_name = f"{question_index:03d} - {question.get_name('Question')}" 383 question_out_dir = os.path.join(quiz.base_dir, group_reldir, question_name) 384 question.to_dir(question_out_dir, fetch_images = fetch_images, context = context, **kwargs) 385 386 # Move the description to a different file. 387 output_data.pop('description', None) 388 if (not quiz.description.is_empty()): 389 edq.util.dirent.write_file(os.path.join(quiz.base_dir, f"{quiz_base_filename}.md"), quiz.description.to_md()) 390 391 edq.util.json.dump_path(output_data, os.path.join(quiz.base_dir, f"{quiz_base_filename}.json"), indent = 4) 392 393class Variant(Quiz): 394 """ 395 A quiz variant is an instantiation of a quiz with specific set of questions chosen for each group. 396 Variants still have question groups, but each group must only have the exact number of questions required for each group 397 (or it is a validation error). 398 399 Variants created directly from quizzes (as opposed to from a JSON file) 400 will already have all the correct components, and will therefore only be lightly validated. 401 Quizzes created from files will undergo full validation. 402 """ 403 404 def __init__(self, 405 quiz_name: str, 406 variant_id: str, 407 **kwargs: typing.Any, 408 ) -> None: 409 super().__init__(**kwargs) 410 411 self.quiz_name: str = quiz_name 412 """ The name of the quiz this variant was generated from. """ 413 414 self.variant_id: str = variant_id 415 """ An identifier to differentiate this variant from its siblings. """ 416 417 @staticmethod 418 def get_dummy( 419 question: quizcomp.model.question.Question, 420 seed: typing.Union[int, None] = None, 421 ) -> 'Variant': 422 """ 423 Get a "dummy" variant that has no real information. 424 """ 425 426 question = typing.cast(quizcomp.model.question.Question, question.copy()) 427 group = quizcomp.model.group.Group(children = [question], **DUMMY_GROUP_DATA.copy()) 428 quiz = Quiz(children = [group], **DUMMY_QUIZ_DATA.copy()) 429 430 return quiz.create_variant(seed = seed)
Default IDs for quiz variants.
41class HideResultsBehavior(enum.Enum): 42 """ 43 The allowed behaviors for hiding results from students for a quiz with multiple attempts. 44 """ 45 46 ALWASY_HIDE = 'always' 47 """ Students can never see their results. """ 48 49 NEVER_HIDE = 'never' 50 """ Students can see their results after each attempt. """ 51 52 UNTIL_AFTER_LAST_ATTEMPT = 'until_after_last_attempt' 53 """ Students can see their results after each attempt. """
The allowed behaviors for hiding results from students for a quiz with multiple attempts.
Students can see their results after each attempt.
Students can see their results after each attempt.
55class ScoringPolicy(enum.Enum): 56 """ 57 The allowed scoring policies for quizzes with multiple attempts. 58 """ 59 60 KEEP_HIGHEST = 'keep_highest' 61 """ Keep the highest score from all attempts. """ 62 63 KEEP_LATEST = 'keep_latest' 64 """ Keep the most recent score from all attempts. """
The allowed scoring policies for quizzes with multiple attempts.
Keep the highest score from all attempts.
Keep the most recent score from all attempts.
66class Quiz(quizcomp.model.base.CoreType): 67 """ 68 A quiz object represents multiple possible assessments (called "variants"). 69 """ 70 71 def __init__(self, 72 children: typing.Union[typing.List[quizcomp.model.group.Group], None] = None, 73 description: typing.Union[quizcomp.parser.document.ParsedDocument, str, None] = None, 74 course_name: typing.Union[str, None] = None, 75 term_name: typing.Union[str, None] = None, 76 date: typing.Union[edq.util.time.Timestamp, None] = None, 77 time_limit_mins: typing.Union[int, None] = None, 78 version: typing.Union[str, None] = None, 79 practice: typing.Union[bool, None] = None, 80 publish: typing.Union[bool, None] = None, 81 assignment_group: typing.Union[str, None] = None, 82 allowed_attempts: typing.Union[int, None] = None, 83 show_correct_answers: typing.Union[bool, None] = None, 84 hide_results: typing.Union[HideResultsBehavior, str, None] = None, 85 scoring_policy: typing.Union[ScoringPolicy, str, None] = None, 86 **kwargs: typing.Any) -> None: 87 # Remove aliases before super construction. 88 kwargs.pop('groups', None) 89 90 super().__init__(children = children, **kwargs) 91 92 self.course_name: typing.Union[str, None] = course_name 93 """ The optional name for the course associated with this quiz. """ 94 95 self.term_name: typing.Union[str, None] = term_name 96 """ The optional name of the term this quiz takes place during (e.g., "Fall 20XX"). """ 97 98 self.date: typing.Union[edq.util.time.Timestamp, None] = date 99 """ The optional date of this quiz. """ 100 101 if (description is None): 102 description = quizcomp.parser.document.ParsedDocument() 103 104 if (isinstance(description, str)): 105 description = quizcomp.parser.document.ParsedDocument.parse_text(description) 106 107 self.description: quizcomp.parser.document.ParsedDocument = description 108 """ The description/prompt for this quiz. """ 109 110 if ((time_limit_mins is not None) and (time_limit_mins < 0)): 111 time_limit_mins = None 112 113 self.time_limit_mins: typing.Union[int, None] = time_limit_mins 114 """ The time limit (in minutes) for this quiz. """ 115 116 self.version: typing.Union[str, None] = version 117 """ The version of this quiz. """ 118 119 self.practice: typing.Union[bool, None] = practice 120 """ 121 Whether this quiz should be considered a "practice" quiz. 122 This may change the behavior of different quizzes when uploaded to different platforms. 123 """ 124 125 self.publish: typing.Union[bool, None] = publish 126 """ 127 Whether this quiz should be considered published on upload. 128 "Published" quizzes are typically visible to students after upload. 129 """ 130 131 self.assignment_group: typing.Union[str, None] = assignment_group 132 """ 133 The name of the assignment group that this quiz should be uploaded under. 134 Unnecessary if this quiz is not uploaded. 135 """ 136 137 self.allowed_attempts: typing.Union[int, None] = allowed_attempts 138 """ The number of attempts a student should have when taking this quiz. """ 139 140 self.show_correct_answers: typing.Union[bool, None] = show_correct_answers 141 """ Show students the correct answer after submission. """ 142 143 if (isinstance(hide_results, str)): 144 if (not edq.util.enum.has_value(HideResultsBehavior, hide_results)): 145 _logger.warning("Unknown enum value for 'hide_results': '%s'. Setting to null.", hide_results) 146 hide_results = None 147 else: 148 hide_results = HideResultsBehavior(hide_results) 149 150 self.hide_results: typing.Union[HideResultsBehavior, None] = hide_results 151 """ The behavior for showing results to students when multiple attempts are allowed. """ 152 153 if (isinstance(scoring_policy, str)): 154 if (not edq.util.enum.has_value(ScoringPolicy, scoring_policy)): 155 _logger.warning("Unknown enum value for 'scoring_policy': '%s'. Setting to null.", scoring_policy) 156 scoring_policy = None 157 else: 158 scoring_policy = ScoringPolicy(scoring_policy) 159 160 self.scoring_policy: typing.Union[ScoringPolicy, None] = scoring_policy 161 """ The scoring behavior when multiple attempts are allowed. """ 162 163 self._validate() 164 165 def _validate(self) -> None: 166 """ Check if this quiz is valid. """ 167 168 if (self.name is None): 169 raise quizcomp.model.errors.QuizValidationError("Quiz name cannot be empty.", context = self) 170 171 def get_groups(self) -> typing.List[quizcomp.model.group.Group]: 172 """ Get all groups for this quiz. """ 173 174 return [typing.cast(quizcomp.model.group.Group, child) for child in self.children] 175 176 def collect_documents(self) -> typing.List[quizcomp.parser.document.ParsedDocument]: 177 return [self.description] 178 179 @classmethod 180 def prep_init_data(cls, 181 data: typing.Dict[str, typing.Any], 182 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 183 ) -> typing.Dict[str, typing.Any]: 184 if (context is None): 185 context = edq.util.serial.SerializationContext() 186 187 data = super().prep_init_data(data, context) 188 189 data['description'] = cls._collect_description(data, context) 190 191 return data 192 193 @classmethod 194 def _collect_description(cls, 195 data: typing.Dict[str, typing.Any], 196 context: edq.util.serial.SerializationContext, 197 ) -> quizcomp.parser.document.ParsedDocument: 198 """ 199 Collect the description from one of several possible locations. 200 201 The description is allowed to appear (in order of priority): 202 1) in the `description` field. 203 2) pointed to by the `description_path` field. 204 3) or be in the same path as the quiz JSON, but with an `.md` extension 205 (e.g., `a/b/my_quiz.json` and `a/b/my_quiz.md`). 206 207 None values will be ignored (but empty values are valid). 208 Will return an empty description if none of these are present. 209 """ 210 211 # If we have a quiz path, use that to resolve paths. 212 default_description_path = None 213 if (context.source_path is not None): 214 context.source_path = os.path.abspath(context.source_path) 215 context.base_dir = os.path.dirname(context.source_path) 216 default_description_path = os.path.splitext(context.source_path)[0] + '.md' 217 218 # Check the `description` field. 219 text = data.get('description', None) 220 if (text is not None): 221 return quizcomp.parser.document.ParsedDocument.parse_text(text, context) 222 223 # Check for an explicitly provided path. 224 description_path = data.get('description_path', None) 225 if (description_path is not None): 226 if (not os.path.isabs(description_path)): 227 description_path = os.path.join(context.base_dir, description_path) 228 229 description_path = os.path.abspath(description_path) 230 231 if (not os.path.isfile(description_path)): 232 raise quizcomp.model.errors.QuestionValidationError( 233 f"Could not find a description at the provided path: '{data['description_path']}' (Absolute Path: '{description_path}').", 234 context = context) 235 236 return quizcomp.parser.document.ParsedDocument.parse_file(description_path) 237 238 # Check for an implicit path. 239 if ((default_description_path is not None) and os.path.isfile(default_description_path)): 240 return quizcomp.parser.document.ParsedDocument.parse_file(default_description_path) 241 242 return quizcomp.parser.document.ParsedDocument() 243 244 def to_dict(self, 245 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 246 ) -> typing.Dict[str, edq.util.serial.PODType]: 247 data = super().to_dict(context) 248 data['groups'] = data.pop('children', data.get('groups', None)) 249 return data 250 251 @classmethod 252 def from_dict(cls, 253 data: typing.Dict[str, edq.util.serial.PODType], 254 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 255 ) -> 'Quiz': 256 data['children'] = data.pop('groups', data.get('children', None)) 257 return super().from_dict(data, context) 258 259 def create_variant(self, 260 seed: typing.Union[int, None] = None, 261 identifiers: typing.Union[typing.List[str], None] = None, 262 all_questions: bool = False, 263 include_solo_identifier: bool = False, 264 ) -> 'Variant': 265 """ A convenience call to create_variants(). """ 266 267 return self.create_variants( 268 count = 1, 269 seed = seed, 270 identifiers = identifiers, 271 all_questions = all_questions, include_solo_identifier = include_solo_identifier, 272 )[0] 273 274 def create_variants(self, 275 count: int = 1, 276 seed: typing.Union[int, None] = None, 277 identifiers: typing.Union[typing.List[str], None] = None, 278 all_questions: bool = False, 279 include_solo_identifier: bool = False, 280 ) -> typing.List['Variant']: 281 """ 282 Create a collection of variants based on this quiz. 283 These variants will share the same question pool, 284 which is influenced by the `pick_with_replacement` config option. 285 286 Setting `include_solo_identifier` to true will include an identifier (e.g., " - A") 287 in the name of variants when only one variant is created. 288 """ 289 290 if (seed is None): 291 seed = random.randint(0, 2**64) 292 293 rng = random.Random(seed) 294 295 if (identifiers is None): 296 identifiers = DEFAULT_VARIANT_IDS 297 298 if (count < 0): 299 raise quizcomp.model.errors.QuizValidationError( 300 f"Variant count must be non-negative, found: {count}.", 301 context = self) 302 303 if (count > len(identifiers)): 304 raise quizcomp.model.errors.QuizValidationError( 305 ('"Not enough variant identifiers supplied.' 306 + f" Got {len(identifiers)} identifiers and {count} requested variants." 307 + f" Given identifiers: {identifiers}."), 308 context = self) 309 310 _logger.debug("Creating %d variants with seed %d.", count, seed) 311 312 all_used_question_indexes: typing.List[typing.Set[int]] = [set() for _ in self.children] 313 variants = [] 314 315 for i in range(count): 316 variant_id: typing.Union[str, None] = None 317 if ((count > 1) or (include_solo_identifier)): 318 variant_id = identifiers[i] 319 320 variants.append(self._create_variant(variant_id, rng, all_used_question_indexes, all_questions)) 321 322 return variants 323 324 def _create_variant(self, 325 variant_id: typing.Union[str, None], 326 rng: random.Random, 327 all_used_question_indexes: typing.List[typing.Set[int]], 328 all_questions: bool, 329 ) -> 'Variant': 330 """ Create a single variant based on this quiz. """ 331 332 new_groups = [] 333 for (i, group) in enumerate(self.get_groups()): 334 questions = group.choose_variant_questions(all_questions, all_used_question_indexes[i], rng) 335 336 group_data = vars(group).copy() 337 group_data['children'] = questions 338 339 new_groups.append(quizcomp.model.group.Group(**group_data)) 340 341 data = vars(self).copy() 342 343 data['name'] = self.name 344 if (variant_id is not None): 345 data['name'] += f" - {variant_id}" 346 347 data['variant_id'] = variant_id 348 data['quiz_name'] = self.name 349 data['children'] = new_groups 350 351 data['version'] = self.version 352 if ((self.version is not None) and (variant_id is not None)): 353 data['version'] = f"{self.version}, Variant: {variant_id}" 354 355 return Variant(**data) 356 357 def to_dir(self, 358 base_dir: str, 359 fetch_images: bool = True, 360 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 361 quiz_base_filename: str = 'quiz', 362 **kwargs: typing.Any) -> None: 363 quiz = typing.cast(Quiz, self.copy()) 364 365 quiz.base_dir = os.path.abspath(base_dir) 366 edq.util.dirent.mkdir(quiz.base_dir) 367 368 if (fetch_images): 369 quiz.fetch_and_update_images() 370 371 output_data = quiz.to_dict(context = context) 372 373 # Write the groups in the quiz, but the questions in their own dirs. 374 for (group_index, group) in enumerate(quiz.get_groups()): 375 group_name = f"{group_index:03d} - {group.get_name('Group')}" 376 group_reldir = os.path.join('questions', group_name) 377 378 # Rewrite the group to use question paths. 379 output_data['groups'][group_index]['questions'] = [group_reldir] # type: ignore[index,call-overload] 380 381 # Output each question. 382 for (question_index, question) in enumerate(group.get_questions()): 383 question_name = f"{question_index:03d} - {question.get_name('Question')}" 384 question_out_dir = os.path.join(quiz.base_dir, group_reldir, question_name) 385 question.to_dir(question_out_dir, fetch_images = fetch_images, context = context, **kwargs) 386 387 # Move the description to a different file. 388 output_data.pop('description', None) 389 if (not quiz.description.is_empty()): 390 edq.util.dirent.write_file(os.path.join(quiz.base_dir, f"{quiz_base_filename}.md"), quiz.description.to_md()) 391 392 edq.util.json.dump_path(output_data, os.path.join(quiz.base_dir, f"{quiz_base_filename}.json"), indent = 4)
A quiz object represents multiple possible assessments (called "variants").
71 def __init__(self, 72 children: typing.Union[typing.List[quizcomp.model.group.Group], None] = None, 73 description: typing.Union[quizcomp.parser.document.ParsedDocument, str, None] = None, 74 course_name: typing.Union[str, None] = None, 75 term_name: typing.Union[str, None] = None, 76 date: typing.Union[edq.util.time.Timestamp, None] = None, 77 time_limit_mins: typing.Union[int, None] = None, 78 version: typing.Union[str, None] = None, 79 practice: typing.Union[bool, None] = None, 80 publish: typing.Union[bool, None] = None, 81 assignment_group: typing.Union[str, None] = None, 82 allowed_attempts: typing.Union[int, None] = None, 83 show_correct_answers: typing.Union[bool, None] = None, 84 hide_results: typing.Union[HideResultsBehavior, str, None] = None, 85 scoring_policy: typing.Union[ScoringPolicy, str, None] = None, 86 **kwargs: typing.Any) -> None: 87 # Remove aliases before super construction. 88 kwargs.pop('groups', None) 89 90 super().__init__(children = children, **kwargs) 91 92 self.course_name: typing.Union[str, None] = course_name 93 """ The optional name for the course associated with this quiz. """ 94 95 self.term_name: typing.Union[str, None] = term_name 96 """ The optional name of the term this quiz takes place during (e.g., "Fall 20XX"). """ 97 98 self.date: typing.Union[edq.util.time.Timestamp, None] = date 99 """ The optional date of this quiz. """ 100 101 if (description is None): 102 description = quizcomp.parser.document.ParsedDocument() 103 104 if (isinstance(description, str)): 105 description = quizcomp.parser.document.ParsedDocument.parse_text(description) 106 107 self.description: quizcomp.parser.document.ParsedDocument = description 108 """ The description/prompt for this quiz. """ 109 110 if ((time_limit_mins is not None) and (time_limit_mins < 0)): 111 time_limit_mins = None 112 113 self.time_limit_mins: typing.Union[int, None] = time_limit_mins 114 """ The time limit (in minutes) for this quiz. """ 115 116 self.version: typing.Union[str, None] = version 117 """ The version of this quiz. """ 118 119 self.practice: typing.Union[bool, None] = practice 120 """ 121 Whether this quiz should be considered a "practice" quiz. 122 This may change the behavior of different quizzes when uploaded to different platforms. 123 """ 124 125 self.publish: typing.Union[bool, None] = publish 126 """ 127 Whether this quiz should be considered published on upload. 128 "Published" quizzes are typically visible to students after upload. 129 """ 130 131 self.assignment_group: typing.Union[str, None] = assignment_group 132 """ 133 The name of the assignment group that this quiz should be uploaded under. 134 Unnecessary if this quiz is not uploaded. 135 """ 136 137 self.allowed_attempts: typing.Union[int, None] = allowed_attempts 138 """ The number of attempts a student should have when taking this quiz. """ 139 140 self.show_correct_answers: typing.Union[bool, None] = show_correct_answers 141 """ Show students the correct answer after submission. """ 142 143 if (isinstance(hide_results, str)): 144 if (not edq.util.enum.has_value(HideResultsBehavior, hide_results)): 145 _logger.warning("Unknown enum value for 'hide_results': '%s'. Setting to null.", hide_results) 146 hide_results = None 147 else: 148 hide_results = HideResultsBehavior(hide_results) 149 150 self.hide_results: typing.Union[HideResultsBehavior, None] = hide_results 151 """ The behavior for showing results to students when multiple attempts are allowed. """ 152 153 if (isinstance(scoring_policy, str)): 154 if (not edq.util.enum.has_value(ScoringPolicy, scoring_policy)): 155 _logger.warning("Unknown enum value for 'scoring_policy': '%s'. Setting to null.", scoring_policy) 156 scoring_policy = None 157 else: 158 scoring_policy = ScoringPolicy(scoring_policy) 159 160 self.scoring_policy: typing.Union[ScoringPolicy, None] = scoring_policy 161 """ The scoring behavior when multiple attempts are allowed. """ 162 163 self._validate()
The optional name of the term this quiz takes place during (e.g., "Fall 20XX").
Whether this quiz should be considered a "practice" quiz. This may change the behavior of different quizzes when uploaded to different platforms.
Whether this quiz should be considered published on upload. "Published" quizzes are typically visible to students after upload.
The name of the assignment group that this quiz should be uploaded under. Unnecessary if this quiz is not uploaded.
The behavior for showing results to students when multiple attempts are allowed.
171 def get_groups(self) -> typing.List[quizcomp.model.group.Group]: 172 """ Get all groups for this quiz. """ 173 174 return [typing.cast(quizcomp.model.group.Group, child) for child in self.children]
Get all groups for this quiz.
176 def collect_documents(self) -> typing.List[quizcomp.parser.document.ParsedDocument]: 177 return [self.description]
Collect documents for this object only (does not include any children). Use collect_all_documents() if you want child documents as well.
179 @classmethod 180 def prep_init_data(cls, 181 data: typing.Dict[str, typing.Any], 182 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 183 ) -> typing.Dict[str, typing.Any]: 184 if (context is None): 185 context = edq.util.serial.SerializationContext() 186 187 data = super().prep_init_data(data, context) 188 189 data['description'] = cls._collect_description(data, context) 190 191 return data
Prepare data to be passed into this class' constructor.
By default, this is called by from_pod(). A child can override this or prep_init_data() depending on the functionality they want.
A general (but inefficient) implementation is provided by default. This implementation will attempt to use type hints (of the classes constructor) to convert enums and DictDeserializers.
244 def to_dict(self, 245 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 246 ) -> typing.Dict[str, edq.util.serial.PODType]: 247 data = super().to_dict(context) 248 data['groups'] = data.pop('children', data.get('groups', None)) 249 return data
Return a dict that can be used to represent this object. If the dict is passed to from_dict(), an identical object should be reconstructed.
A general (but inefficient) implementation is provided by default.
251 @classmethod 252 def from_dict(cls, 253 data: typing.Dict[str, edq.util.serial.PODType], 254 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 255 ) -> 'Quiz': 256 data['children'] = data.pop('groups', data.get('children', None)) 257 return super().from_dict(data, context)
Return an instance of this subclass created using the given dict. If the dict came from to_dict(), the returned object should be equivalent to the original.
By default, this function just calls the class' constructor with the output of prep_init_data(). A child can override this or prep_init_data() depending on the functionality they want.
A general (but inefficient) implementation is provided by default. This implementation will attempt to use type hints (of the classes constructor) to convert enums and DictDeserializers.
259 def create_variant(self, 260 seed: typing.Union[int, None] = None, 261 identifiers: typing.Union[typing.List[str], None] = None, 262 all_questions: bool = False, 263 include_solo_identifier: bool = False, 264 ) -> 'Variant': 265 """ A convenience call to create_variants(). """ 266 267 return self.create_variants( 268 count = 1, 269 seed = seed, 270 identifiers = identifiers, 271 all_questions = all_questions, include_solo_identifier = include_solo_identifier, 272 )[0]
A convenience call to create_variants().
274 def create_variants(self, 275 count: int = 1, 276 seed: typing.Union[int, None] = None, 277 identifiers: typing.Union[typing.List[str], None] = None, 278 all_questions: bool = False, 279 include_solo_identifier: bool = False, 280 ) -> typing.List['Variant']: 281 """ 282 Create a collection of variants based on this quiz. 283 These variants will share the same question pool, 284 which is influenced by the `pick_with_replacement` config option. 285 286 Setting `include_solo_identifier` to true will include an identifier (e.g., " - A") 287 in the name of variants when only one variant is created. 288 """ 289 290 if (seed is None): 291 seed = random.randint(0, 2**64) 292 293 rng = random.Random(seed) 294 295 if (identifiers is None): 296 identifiers = DEFAULT_VARIANT_IDS 297 298 if (count < 0): 299 raise quizcomp.model.errors.QuizValidationError( 300 f"Variant count must be non-negative, found: {count}.", 301 context = self) 302 303 if (count > len(identifiers)): 304 raise quizcomp.model.errors.QuizValidationError( 305 ('"Not enough variant identifiers supplied.' 306 + f" Got {len(identifiers)} identifiers and {count} requested variants." 307 + f" Given identifiers: {identifiers}."), 308 context = self) 309 310 _logger.debug("Creating %d variants with seed %d.", count, seed) 311 312 all_used_question_indexes: typing.List[typing.Set[int]] = [set() for _ in self.children] 313 variants = [] 314 315 for i in range(count): 316 variant_id: typing.Union[str, None] = None 317 if ((count > 1) or (include_solo_identifier)): 318 variant_id = identifiers[i] 319 320 variants.append(self._create_variant(variant_id, rng, all_used_question_indexes, all_questions)) 321 322 return variants
Create a collection of variants based on this quiz.
These variants will share the same question pool,
which is influenced by the pick_with_replacement config option.
Setting include_solo_identifier to true will include an identifier (e.g., " - A")
in the name of variants when only one variant is created.
357 def to_dir(self, 358 base_dir: str, 359 fetch_images: bool = True, 360 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 361 quiz_base_filename: str = 'quiz', 362 **kwargs: typing.Any) -> None: 363 quiz = typing.cast(Quiz, self.copy()) 364 365 quiz.base_dir = os.path.abspath(base_dir) 366 edq.util.dirent.mkdir(quiz.base_dir) 367 368 if (fetch_images): 369 quiz.fetch_and_update_images() 370 371 output_data = quiz.to_dict(context = context) 372 373 # Write the groups in the quiz, but the questions in their own dirs. 374 for (group_index, group) in enumerate(quiz.get_groups()): 375 group_name = f"{group_index:03d} - {group.get_name('Group')}" 376 group_reldir = os.path.join('questions', group_name) 377 378 # Rewrite the group to use question paths. 379 output_data['groups'][group_index]['questions'] = [group_reldir] # type: ignore[index,call-overload] 380 381 # Output each question. 382 for (question_index, question) in enumerate(group.get_questions()): 383 question_name = f"{question_index:03d} - {question.get_name('Question')}" 384 question_out_dir = os.path.join(quiz.base_dir, group_reldir, question_name) 385 question.to_dir(question_out_dir, fetch_images = fetch_images, context = context, **kwargs) 386 387 # Move the description to a different file. 388 output_data.pop('description', None) 389 if (not quiz.description.is_empty()): 390 edq.util.dirent.write_file(os.path.join(quiz.base_dir, f"{quiz_base_filename}.md"), quiz.description.to_md()) 391 392 edq.util.json.dump_path(output_data, os.path.join(quiz.base_dir, f"{quiz_base_filename}.json"), indent = 4)
Write this object to the given directory. This is different than to_path(), as that function just serializes the to a single JSON file, whereas this method writes a directory in the Quiz Composer style.
Inherited Members
- quizcomp.model.base.CoreType
- serialization_omit_none
- serialization_omit_empty
- serialization_error_class
- serialization_skip_fields
- base_dir
- source_path
- name
- parent
- children
- points
- lms_id
- attributes
- attributes_first
- attributes_last
- hints
- hints_first
- hints_last
- style
- style_first
- style_last
- child_count
- get_name
- get_parent_index
- collect_all_documents
- get_points
- get_child_points
- get_display_points
- get_attribute
- get_hint
- get_style
- get_config
- get_known_config
- to_path
- fetch_and_update_images
- copy
394class Variant(Quiz): 395 """ 396 A quiz variant is an instantiation of a quiz with specific set of questions chosen for each group. 397 Variants still have question groups, but each group must only have the exact number of questions required for each group 398 (or it is a validation error). 399 400 Variants created directly from quizzes (as opposed to from a JSON file) 401 will already have all the correct components, and will therefore only be lightly validated. 402 Quizzes created from files will undergo full validation. 403 """ 404 405 def __init__(self, 406 quiz_name: str, 407 variant_id: str, 408 **kwargs: typing.Any, 409 ) -> None: 410 super().__init__(**kwargs) 411 412 self.quiz_name: str = quiz_name 413 """ The name of the quiz this variant was generated from. """ 414 415 self.variant_id: str = variant_id 416 """ An identifier to differentiate this variant from its siblings. """ 417 418 @staticmethod 419 def get_dummy( 420 question: quizcomp.model.question.Question, 421 seed: typing.Union[int, None] = None, 422 ) -> 'Variant': 423 """ 424 Get a "dummy" variant that has no real information. 425 """ 426 427 question = typing.cast(quizcomp.model.question.Question, question.copy()) 428 group = quizcomp.model.group.Group(children = [question], **DUMMY_GROUP_DATA.copy()) 429 quiz = Quiz(children = [group], **DUMMY_QUIZ_DATA.copy()) 430 431 return quiz.create_variant(seed = seed)
A quiz variant is an instantiation of a quiz with specific set of questions chosen for each group. Variants still have question groups, but each group must only have the exact number of questions required for each group (or it is a validation error).
Variants created directly from quizzes (as opposed to from a JSON file) will already have all the correct components, and will therefore only be lightly validated. Quizzes created from files will undergo full validation.
405 def __init__(self, 406 quiz_name: str, 407 variant_id: str, 408 **kwargs: typing.Any, 409 ) -> None: 410 super().__init__(**kwargs) 411 412 self.quiz_name: str = quiz_name 413 """ The name of the quiz this variant was generated from. """ 414 415 self.variant_id: str = variant_id 416 """ An identifier to differentiate this variant from its siblings. """
418 @staticmethod 419 def get_dummy( 420 question: quizcomp.model.question.Question, 421 seed: typing.Union[int, None] = None, 422 ) -> 'Variant': 423 """ 424 Get a "dummy" variant that has no real information. 425 """ 426 427 question = typing.cast(quizcomp.model.question.Question, question.copy()) 428 group = quizcomp.model.group.Group(children = [question], **DUMMY_GROUP_DATA.copy()) 429 quiz = Quiz(children = [group], **DUMMY_QUIZ_DATA.copy()) 430 431 return quiz.create_variant(seed = seed)
Get a "dummy" variant that has no real information.
Inherited Members
- Quiz
- course_name
- term_name
- date
- description
- time_limit_mins
- version
- practice
- publish
- assignment_group
- allowed_attempts
- show_correct_answers
- hide_results
- scoring_policy
- get_groups
- collect_documents
- prep_init_data
- to_dict
- from_dict
- create_variant
- create_variants
- to_dir
- quizcomp.model.base.CoreType
- serialization_omit_none
- serialization_omit_empty
- serialization_error_class
- serialization_skip_fields
- base_dir
- source_path
- name
- parent
- children
- points
- lms_id
- attributes
- attributes_first
- attributes_last
- hints
- hints_first
- hints_last
- style
- style_first
- style_last
- child_count
- get_name
- get_parent_index
- collect_all_documents
- get_points
- get_child_points
- get_display_points
- get_attribute
- get_hint
- get_style
- get_config
- get_known_config
- to_path
- fetch_and_update_images
- copy