class MultipleAnswerMCQExerciseQuestion(MultipleAnswerMCQExerciseQuestionJsonSerializer, ExerciseQuestion): """Multiple choice question with several possible answers.""" ## Object Id _id = db.ObjectIdField(default=ObjectId) ## Propositions propositions = db.ListField(db.EmbeddedDocumentField(MultipleAnswerMCQExerciseQuestionProposition)) def without_correct_answer(self): son = super(MultipleAnswerMCQExerciseQuestion, self).without_correct_answer() for proposition in son['propositions']: proposition.pop('is_correct_answer', None) return son def answer_with_data(self, data): return MultipleAnswerMCQExerciseQuestionAnswer.init_with_data(data) def get_propositions_by_id(self, propositions_id): result = [] for proposition in self.propositions: print(str(proposition._id)) if proposition._id in propositions_id: result.append(proposition) return result
class DropdownExerciseQuestion(DropdownExerciseQuestionJsonSerializer, ExerciseQuestion): """question where blanks need to be filled with word chosen from a dropdown list.""" ## Object Id _id = db.ObjectIdField(default=ObjectId) ## the whole text, where blanks are tagged with [%N%] where N if Nth blank of the text text = db.StringField() ## Propositions dropdowns = db.ListField( db.EmbeddedDocumentField(DropdownExerciseQuestionDropdown)) def without_correct_answer(self): son = super(DropdownExerciseQuestion, self).without_correct_answer() for dropdown in son['dropdowns']: for proposition in dropdown['propositions']: proposition.pop('is_correct_answer', None) return son def answer_with_data(self, data): return DropdownExerciseQuestionAnswer.init_with_data(data) def get_propositions_by_id(self, propositionsId): result = [] for dropdown in self.dropdowns: for proposition in dropdown.propositions: if proposition._id in propositionsId: result.append(proposition) return result
class RightOrWrongExerciseQuestion(RightOrWrongExerciseQuestionJsonSerializer, ExerciseQuestion): """Question with a right or wrong answer""" ## Object Id _id = db.ObjectIdField(default=ObjectId) ## Propositions propositions = db.ListField( db.EmbeddedDocumentField(RightOrWrongExerciseQuestionProposition)) def without_correct_answer(self): son = super(RightOrWrongExerciseQuestion, self).without_correct_answer() for proposition in son['propositions']: proposition.pop('is_correct_answer', None) return son def answer_with_data(self, data): return RightOrWrongExerciseQuestionAnswer.init_with_data(data) def get_proposition_by_id(self, propositionId): result = None for proposition in self.propositions: if proposition._id == propositionId: result = proposition return result
class CategorizeExerciseQuestion(CategorizeExerciseQuestionJsonSerializer, ExerciseQuestion): """A list of items that need to be categorized.""" ## Object Id _id = db.ObjectIdField(default=ObjectId) ## categories categories = db.ListField( db.EmbeddedDocumentField(CategorizeExerciseQuestionCategory)) def without_correct_answer(self): son = super(CategorizeExerciseQuestion, self).without_correct_answer() all_items = [] for category in son['categories']: all_items.extend(category.pop('items')) shuffle(all_items) son['items'] = all_items return son def answer_with_data(self, data): return CategorizeExerciseQuestionAnswer.init_with_data(data) def get_category_by_id(self, category_id): for category in self.categories: if category._id == category_id: return category def get_items_in_category_by_id(self, category_id, items_id): result = [] category = self.get_category_by_id(category_id) for item in category.items: if item._id in items_id: result.append(item) return result
class UniqueAnswerMCQExerciseQuestion( UniqueAnswerMCQExerciseQuestionJsonSerializer, ExerciseQuestion): """Multiple choice question with one possible answer only.""" ## Object Id _id = db.ObjectIdField(default=ObjectId) ## Propositions propositions = db.ListField( db.EmbeddedDocumentField(UniqueAnswerMCQExerciseQuestionProposition)) def without_correct_answer(self): son = super(UniqueAnswerMCQExerciseQuestion, self).without_correct_answer() for proposition in son['propositions']: proposition.pop('is_correct_answer', None) return son def answer_with_data(self, data): return UniqueAnswerMCQExerciseQuestionAnswer.init_with_data(data) def get_proposition_by_id(self, propositionId): result = None for proposition in self.propositions: if proposition._id == propositionId: result = proposition return result
class OrderingExerciseQuestion(OrderingExerciseQuestionOrderingExerciseQuestionItemJsonSerializer, ExerciseQuestion): """A list of items that need to be ordered. May be horizontal or vertical""" ## Object Id _id = db.ObjectIdField(default=ObjectId) ## Propositions items = db.ListField(db.EmbeddedDocumentField(OrderingExerciseQuestionItem)) def without_correct_answer(self): son = super(OrderingExerciseQuestion, self).without_correct_answer() shuffle(son['items']) return son def answer_with_data(self, data): return OrderingExerciseQuestionAnswer.init_with_data(data) def get_items_by_id(self, itemsId): result = [] for itemId in itemsId: print(itemId) result.append(self.get_item_by_id(itemId)) return result def get_item_by_id(self, itemId): for item in self.items: if str(item._id) == itemId: return item return None
class DropdownExerciseQuestionDropdown( DropdownExerciseQuestionDropdownJsonSerializer, db.EmbeddedDocument): """Stores a list of propositions to a blank field in a text.""" ## Object Id _id = db.ObjectIdField(default=ObjectId) ## Text propositions = db.ListField( db.EmbeddedDocumentField(DropdownExerciseQuestionProposition))
class CategorizeExerciseQuestionCategory( CategorizeExerciseQuestionCategoryJsonSerializer, db.EmbeddedDocument): """Stores a category for the categorize question.""" ## Object Id _id = db.ObjectIdField(default=ObjectId) ## Text title = db.StringField() ## items that belong to this category items = db.ListField( db.EmbeddedDocumentField(CategorizeExerciseQuestionItem))
class ExternalVideoResource(ExternalVideoResourceJsonSerializer, Resource): """References a video from the Internet.""" resource_content = db.EmbeddedDocumentField(ExternalVideoResourceContent)
class VideoResource(VideoResourceJsonSerializer, LinkedFileResource): """Stores a video file in the database.""" resource_content = db.EmbeddedDocumentField(VideoResourceContent)
class ExerciseResource(ExerciseResourceJsonSerializer, Resource): """An exercise with a list of questions.""" resource_content = db.EmbeddedDocumentField(ExerciseResourceContent) def _add_instance(self, obj): """This is a hack to provide the ``_instance`` property to the shorthand question-getters.""" def _add_instance_single_object(obj): obj._instance = self return obj if isinstance(obj, list): return map(_add_instance_single_object, obj) else: return _add_instance_single_object(obj) @property def questions(self): """A shorthand getter for the list of questions in the resource content.""" questions = self.resource_content.questions return self._add_instance(questions) def question(self, question_id): """A shorthand getter for a question with a known `_id`.""" question = self.resource_content.question(question_id) return self._add_instance(question) def random_questions(self, number=None): """ A shorthand getter for a list of random questions. See the documentation of `ExerciseResourceContent.random_questions`. """ questions = self.resource_content.random_questions(number) return self._add_instance(questions) def all_synced_documents(self, local_server=None): items = [] if self.resource_content.fail_linked_resource: items.append(self.resource_content.fail_linked_resource) items.extend( super(ExerciseResource, self).all_synced_documents(local_server=local_server)) return items def user_analytics(self, user): rv = super(ExerciseResource, self).user_analytics(user) from MookAPI.services import exercise_attempts exercise_attempts = exercise_attempts.find( user=user, exercise=self).order_by('-date') rv['last_attempts_scores'] = map( lambda a: { "date": a.date, "nb_questions": a.nb_questions, "score": a.nb_right_answers }, exercise_attempts[:5]) nb_finished_attempts = 0 total_duration = datetime.timedelta(0) for attempt in exercise_attempts: if attempt.duration: nb_finished_attempts += 1 total_duration += attempt.duration if nb_finished_attempts > 0: rv['average_time_on_exercise'] = math.floor( (total_duration / nb_finished_attempts).total_seconds()) else: rv['average_time_on_exercise'] = 0 rv['nb_attempts'] = exercise_attempts.count() return rv
class Skill(SkillJsonSerializer, ResourceHierarchy): """ .. _Skill: Second level of Resource_ hierarchy. Their ascendants are Track_ objects. Their descendants are Lesson_ objects. """ ### PROPERTIES ## Parent track track = db.ReferenceField('Track') """The parent Track_.""" ## icon image icon = db.ImageField() """An icon to illustrate the Skill_.""" ## short description short_description = db.StringField() """The short description of the skill, to appear where there is not enough space for the long one.""" ## skill validation test validation_exercise = db.EmbeddedDocumentField(SkillValidationExercise) """The exercise that the user might take to validate the skill.""" ### VIRTUAL PROPERTIES @property def icon_url(self, _external=True): """The URL where the skill icon can be downloaded.""" return url_for("hierarchy.get_skill_icon", skill_id=self.id, _external=_external) @property def url(self, _external=False): return url_for("hierarchy.get_skill", skill_id=self.id, _external=_external) @property def lessons(self): """A queryset of the Lesson_ objects that belong to the current Skill_.""" from MookAPI.services import lessons return lessons.find(skill=self).order_by('order', 'title') @property def lessons_refs(self): return [lesson.to_json_dbref() for lesson in self.lessons] def is_validated_by_user(self, user): """Whether the current_user validated the hierarchy level based on their activity.""" from MookAPI.services import completed_skills return completed_skills.find( skill=self, user=user, is_validated_through_test=True).count() > 0 def user_progress(self, user): current = 0 nb_resources = 0 for lesson in self.lessons: for resource in lesson.resources: nb_resources += 1 if resource.is_validated_by_user(user): current += 1 return {'current': current, 'max': nb_resources} @property def bg_color(self): return self.track.bg_color @property def hierarchy(self): return [self.track.to_json_dbref(), self.to_json_dbref()] ### METHODS def _add_instance(self, obj): """This is a hack to provide the ``_instance`` property to the shorthand question-getters.""" def _add_instance_single_object(obj): obj._instance = self return obj if isinstance(obj, list): return map(_add_instance_single_object, obj) else: return _add_instance_single_object(obj) def user_analytics(self, user): analytics = super(Skill, self).user_analytics(user) from MookAPI.services import skill_validation_attempts, visited_skills skill_validation_attempts = skill_validation_attempts.find( user=user).order_by('-date') analytics['last_attempts_scores'] = map( lambda a: { "date": a.date, "nb_questions": a.nb_questions, "score": a.nb_right_answers }, skill_validation_attempts[:5]) nb_finished_attempts = 0 total_duration = datetime.timedelta(0) for attempt in skill_validation_attempts: if attempt.duration: nb_finished_attempts += 1 total_duration += attempt.duration if nb_finished_attempts > 0: analytics['average_time_on_exercise'] = math.floor( (total_duration / nb_finished_attempts).total_seconds()) else: analytics['average_time_on_exercise'] = 0 analytics['nb_attempts'] = skill_validation_attempts.count() analytics['nb_visits'] = visited_skills.find(user=user, skill=self).count() return analytics def top_level_syncable_document(self): return self.track def all_synced_documents(self, local_server=None): items = super(Skill, self).all_synced_documents(local_server=local_server) for lesson in self.lessons: items.extend( lesson.all_synced_documents(local_server=local_server)) return items @property def questions(self): """A list of all children exercises' questions, whatever their type.""" from MookAPI.services import exercise_resources questions = [] for l in self.lessons: for r in l.resources: if exercise_resources._isinstance(r): questions.extend(r.questions) return questions def question(self, question_id): """A shorthand getter for a question with a known `_id`.""" from MookAPI.services import exercise_resources oid = bson.ObjectId(question_id) for l in self.lessons: for r in l.resources: if exercise_resources._isinstance(r): for q in r.questions: if q._id == oid: return r._add_instance(q) return None def random_questions(self, number=None): """ A shorthand getter for a list of random questions. See the documentation of `SkillValidationExercise.random_questions`. """ questions = self.validation_exercise.random_questions(self, number) return self._add_instance(questions)
class AudioResource(AudioResourceJsonSerializer, LinkedFileResource): """Stores an audio file in the database.""" resource_content = db.EmbeddedDocumentField(AudioResourceContent)
class RichTextResource(RichTextResourceJsonSerializer, Resource): """Store rich text content.""" resource_content = db.EmbeddedDocumentField(RichTextResourceContent)
class SkillValidationAttempt(SkillValidationAttemptJsonSerializer, Activity): """ Records any attempt at an exercise. """ ### PROPERTIES ## Skill skill = db.ReferenceField('Skill') @property def object(self): return self.skill ## Question answers question_answers = db.ListField( db.EmbeddedDocumentField(ExerciseAttemptQuestionAnswer)) is_validated = db.BooleanField(default=False) """ Is exercise validated """ end_date = db.DateTimeField() """ The date when the attempt is ended """ @property def max_mistakes(self): if self.skill and not isinstance(self.skill, DBRef): return self.skill.validation_exercise.max_mistakes return None @property def nb_right_answers(self): return len( filter(lambda qa: qa.is_answered_correctly, self.question_answers)) @property def nb_questions(self): return len(self.question_answers) @property def duration(self): if not self.end_date: return None else: return self.end_date - self.date ### METHODS @classmethod def init_with_skill(cls, skill): """Initiate an validation attempt for a given skill.""" obj = cls() obj.skill = skill questions = skill.random_questions() obj.question_answers = map( lambda q: ExerciseAttemptQuestionAnswer.init_with_question(q), questions) return obj def clean(self): super(SkillValidationAttempt, self).clean() self.type = "exercise_attempt" def __unicode__(self): if self.skill is not None: return self.skill.title return self.id def question_answer(self, question_id): oid = ObjectId(question_id) for question_answer in self.question_answers: if question_answer.question_id == oid: return question_answer raise KeyError("Question not found.") def set_question_answer(self, question_id, question_answer): oid = ObjectId(question_id) for (index, qa) in enumerate(self.question_answers): if qa.question_id == oid: self.question_answers[index] = question_answer return question_answer raise KeyError("Question not found.") def save_answer(self, question_id, data): """ Saves an answer (ExerciseQuestionAnswer) to a question (referenced by its ObjectId). """ question = self.skill.question(question_id) attempt_question_answer = self.question_answer(question_id) question_answer = question.answer_with_data(data) attempt_question_answer.given_answer = question_answer attempt_question_answer.is_answered_correctly = question_answer.is_correct( question, attempt_question_answer.parameters) self.set_question_answer(question_id, attempt_question_answer) def start_question(self, question_id): """ Records the datetime at which the given question has been started :param question_id: the id of the question which has just been started """ attempt_question_answer = self.question_answer(question_id) attempt_question_answer.asked_date = datetime.datetime.now def end(self): """ Records the "now" date as the date when the user has finished the attempt """ self.end_date = datetime.datetime.now def is_skill_validation_validated(self): nb_total_questions = len(self.question_answers) answered_questions = filter(lambda a: a.given_answer is not None, self.question_answers) return len(answered_questions) >= nb_total_questions def is_attempt_completed(self): nb_total_questions = len(self.question_answers) nb_max_mistakes = self.skill.validation_exercise.max_mistakes answered_questions = filter(lambda a: a.given_answer is not None, self.question_answers) if len(answered_questions) >= nb_total_questions: right_answers = filter(lambda a: a.is_answered_correctly, answered_questions) if len(right_answers) >= nb_total_questions - nb_max_mistakes: return True return False
class LinkedFileResource(Resource): """Stores a file in the database.""" resource_content = db.EmbeddedDocumentField(LinkedFileResourceContent)
class ExerciseResourceContent(ExerciseResourceContentJsonSerializer, ResourceContent): unique_answer_mcq_questions = db.ListField( db.EmbeddedDocumentField(UniqueAnswerMCQExerciseQuestion)) """A (possibly empty) list of unique-answer multiple-choice questions (`UniqueAnswerMCQExerciseQuestion`).""" multiple_answer_mcq_questions = db.ListField( db.EmbeddedDocumentField(MultipleAnswerMCQExerciseQuestion)) """A (possibly empty) list of multiple-answer multiple-choice questions (`MultipleAnswerMCQExerciseQuestion`).""" right_or_wrong_questions = db.ListField( db.EmbeddedDocumentField(RightOrWrongExerciseQuestion)) """A (possibly empty) list of multiple-answer multiple-choice questions (`RightOrWrongExerciseQuestion`).""" dropdown_questions = db.ListField( db.EmbeddedDocumentField(DropdownExerciseQuestion)) """A (possibly empty) list of dropdown questions (`DropdownExerciseQuestion`).""" ordering_questions = db.ListField( db.EmbeddedDocumentField(OrderingExerciseQuestion)) """A (possibly empty) list of ordering questions (`OrderingExerciseQuestion`).""" categorize_questions = db.ListField( db.EmbeddedDocumentField(CategorizeExerciseQuestion)) """A (possibly empty) list of categorizing questions (`CategorizeExerciseQuestion`).""" @property def questions(self): """A list of all questions, whatever their type.""" questions = [] questions.extend(self.unique_answer_mcq_questions) questions.extend(self.multiple_answer_mcq_questions) questions.extend(self.right_or_wrong_questions) questions.extend(self.dropdown_questions) questions.extend(self.ordering_questions) questions.extend(self.categorize_questions) return questions def question(self, question_id): """A getter for a question with a known `_id`.""" oid = bson.ObjectId(question_id) for question in self.questions: if question._id == oid: return question raise exceptions.KeyError("Question not found.") def random_questions(self, number=None): """ A list of random questions. If `number` is not specified, it will be set to the exercise's `number_of_questions` property. The list will contain `number` questions, or all questions if there are not enough questions in the exercise. """ if not number: number = self.number_of_questions or len(self.questions) all_questions = self.questions random.shuffle(all_questions) return all_questions[:number] number_of_questions = db.IntField() """The number of questions to ask from this exercise.""" max_mistakes = db.IntField() """The number of mistakes authorized before failing the exercise.""" fail_linked_resource = db.ReferenceField(Resource) """A resource to look again when failing the exercise.""" def clean(self): super(ExerciseResourceContent, self).clean() # FIXME This should be done in validate and raise an error. Do that when MongoEngine is fixed. if self.fail_linked_resource: if self.fail_linked_resource.track != self._instance.track: self.fail_linked_resource = None
class Resource(ResourceJsonSerializer, SyncableDocument): """ .. _Resource: Any elementary pedagogical resource. Contains the metadata and a ResourceContent_ embedded document. Resource_ objects are organized by Lesson_ objects, *i.e.* each Resource_ references a parent Lesson_. """ meta = {'allow_inheritance': True} ### PROPERTIES is_published = db.BooleanField(default=True) """Whether the resource should appear on the platform""" is_additional = db.BooleanField(default=False) """True if the resource is an additional resource, i.e. has a Resource parent (instead of a Lesson)""" title = db.StringField(required=True) """The title of the Resource_.""" slug = db.StringField(unique=True) """A human-readable unique identifier for the Resource_.""" ## Will be implemented later # creator = db.ReferenceField('User') # """The user who created the resource.""" description = db.StringField() """A text describing the Resource_.""" order = db.IntField() """The order of the Resource_ in the Lesson_.""" keywords = db.ListField(db.StringField()) """A list of keywords to index the Resource_.""" date = db.DateTimeField(default=datetime.datetime.now, required=True) """The date the Resource_ was created.""" parent = db.ReferenceField('Lesson') """The parent hierarchy object (usually Lesson, but can be overridden).""" parent_resource = db.ReferenceField('Resource') """The parent hierarchy object (usually Lesson, but can be overridden).""" resource_content = db.EmbeddedDocumentField(ResourceContent) """The actual content of the Resource_, stored in a ResourceContent_ embedded document.""" ### VIRTUAL PROPERTIES @property def url(self, _external=False): return url_for("resources.get_resource", resource_id=self.id, _external=_external) def is_validated_by_user(self, user): """Whether the current user (if any) has validated this Resource_.""" from MookAPI.services import completed_resources return completed_resources.find(resource=self, user=user).count() > 0 @property def is_validated(self): try: verify_jwt() except: pass if not current_user: return None return self.is_validated_by_user(current_user.user) @property def skill(self): """Shorthand virtual property to the parent Skill_ of the parent Lesson_.""" if self.parent_resource: return self.parent_resource.skill if not isinstance( self.parent_resource, DBRef) else None return self.parent.skill if not isinstance(self.parent, DBRef) else None @property def track(self): """Shorthand virtual property to the parent Track_ of the parent Skill_ of the parent Lesson_.""" if self.skill and not isinstance(self.skill, DBRef): return self.skill.track return None @property def additional_resources(self): """A queryset of the Resources_ objects that are additional resources to the current Resource_.""" from MookAPI.services import resources return resources.find(parent_resource=self).order_by('order', 'title') @property def additional_resources_refs(self): return [ additional_resource.to_json_dbref() for additional_resource in self.additional_resources ] ### METHODS def siblings(self): """A queryset of Resource_ objects in the same Lesson_, including the current Resource_.""" return Resource.objects.order_by('order', 'title').filter(parent=self.parent) def siblings_strict(self): """A queryset of Resource_ objects in the same Lesson_, excluding the current Resource_.""" return Resource.objects.order_by('order', 'title').filter(parent=self.parent, id__ne=self.id) def aunts(self): """A queryset of Lesson_ objects in the same Skill_, including the current Lesson_.""" return self.parent.siblings() def aunts_strict(self): """A queryset of Lesson_ objects in the same Skill_, excluding the current Lesson_.""" return self.parent.siblings_strict() def cousins(self): """A queryset of Resource_ objects in the same Skill_, including the current Resource_.""" return Resource.objects.order_by( 'parent', 'order', 'title').filter(parent__in=self.aunts()) def cousins_strict(self): """A queryset of Resource_ objects in the same Skill_, excluding the current Resource_.""" return Resource.objects.order_by( 'parent', 'order', 'title').filter(parent__in=self.aunts_strict()) def _set_slug(self): """Sets a slug for the hierarchy level based on the title.""" slug = self.slug or slugify(self.title) def alternate_slug(text, k=1): return text if k <= 1 else "{text}-{k}".format(text=text, k=k) k = 0 kmax = 10**4 while k < kmax: if self.id is None: req = self.__class__.objects(slug=alternate_slug(slug, k)) else: req = self.__class__.objects(slug=alternate_slug(slug, k), id__ne=self.id) if len(req) > 0: k = k + 1 continue else: break self.slug = alternate_slug(slug, k) if k <= kmax else None def clean(self): super(Resource, self).clean() self._set_slug() @property def bg_color(self): return self.track.bg_color def user_analytics(self, user): from MookAPI.services import visited_resources return dict( nb_visits=visited_resources.find(user=user, resource=self).count()) def user_info(self, user, analytics=False): info = dict(is_validated=self.is_validated_by_user(user)) if analytics: info['analytics'] = self.user_analytics(user) return info @property def hierarchy(self): """ Returns an array of the breadcrumbs up until the current object: [Track_, Skill_, Lesson_, Resource_] """ rv = [] if self.is_additional: rv.extend([ self.parent_resource.track.to_json_dbref(), self.parent_resource.skill.to_json_dbref(), self.parent_resource.parent.to_json_dbref(), self.parent_resource.to_json_dbref(), self.to_json_dbref() ]) else: rv.extend([ self.track.to_json_dbref(), self.skill.to_json_dbref(), self.parent.to_json_dbref(), self.to_json_dbref() ]) return rv def __unicode__(self): return "%s [%s]" % (self.title, self.__class__.__name__) def top_level_syncable_document(self): return self.track def all_synced_documents(self, local_server=None): items = super(Resource, self).all_synced_documents(local_server=local_server) for additional_resource in self.additional_resources: items.extend( additional_resource.all_synced_documents( local_server=local_server)) return items
class DownloadableFileResource(DownloadableFileResourceJsonSerializer, LinkedFileResource): """Stores a downloadable file in the database.""" resource_content = db.EmbeddedDocumentField( DownloadableFileResourceContent)