class ExerciseQuestionAnswer(ExerciseQuestionAnswerJsonSerializer, db.EmbeddedDocument): """ Generic collection to save answers given to a question. Subclasses should define their own properties to store the given answer. They must also override method "is_correct" in order to determine whether the given answer is correct. """ meta = {'allow_inheritance': True, 'abstract': True} ### PROPERTIES date = db.DateTimeField(default=datetime.datetime.now, required=True) """ the date at which the answer has been given """ ### METHODS @classmethod def init_with_data(cls, data): raise exceptions.NotImplementedError( "This question type has no initializer.") ## This method needs to be overridden for each question type. def is_correct(self, question, parameters): """ Whether the answer is correct. Pass the question itself and the parameters, if any. """ raise exceptions.NotImplementedError( "This question type has no correction method.")
class DeletedSyncableDocument(JsonSerializer, db.Document): """ .. _DeletedSyncableDocument: A collection that references all SyncableDocument_ objects that have been deleted, so that they can be deleted on local servers at the next synchronization. The ``delete`` method of SyncableDocument_ creates a ``DeletedSyncableDocument`` object. """ document = db.GenericReferenceField() """A DBRef to the document that was deleted.""" top_level_document = db.GenericReferenceField() """A DBRef to the top-level document of the deleted document, if this is a child document.""" date = db.DateTimeField() """The date at which the document was deleted.""" def save(self, *args, **kwargs): if self.date is None: self.date = datetime.datetime.now() return super(DeletedSyncableDocument, self).save(*args, **kwargs)
class SyncableDocument(SyncableDocumentJsonSerializer, db.Document): """ .. _SyncableDocument: An abstract class for any document that needs to be synced between the central server and a local server. """ meta = {'allow_inheritance': True, 'abstract': True} ## Last modification last_modification = db.DateTimeField() """ The date of the last modification on the document. Used to determine whether the document has changed since the last synchronization of a local server. """ ## Id of the document on the central server central_id = db.ObjectIdField() """The id of the document on the central server.""" # A placeholder for unresolved references that need to be saved after the document is saved unresolved_references = [] @property def url(self, _external=False): """ The URL where a JSON representation of the document based on MongoCoderMixin_'s encode_mongo_ method can be found. .. warning:: Subclasses of MongoCoderDocument should implement this method. """ raise exceptions.NotImplementedError( "The single-object URL of this document class is not defined.") def top_level_syncable_document(self): """ If a ``SyncableDocument`` has child documents, this function returns the top-level parent document. Defaults to ``self``. .. note:: Override this method if the document is a child of a ``SyncableDocument``. """ return self def save(self, *args, **kwargs): self.last_modification = datetime.datetime.now() rv = super(SyncableDocument, self).save(*args, **kwargs) for ref in self.unresolved_references: ref.document = self ref.save() return rv def delete(self, *args, **kwargs): reference = DeletedSyncableDocument() reference.document = self reference.top_level_document = self.top_level_syncable_document() reference.save() return super(SyncableDocument, self).delete(*args, **kwargs) def all_synced_documents(self, local_server=None): """ Returns the list of references to atomic documents that should be looked at when syncing this document. Defaults to a one-element list containing a reference to self. .. note:: Override this method if this document has children documents. Children documents who reference this document should be inserted AFTER self. Children documents referenced from this document should be inserted BEFORE self. """ return [self] def items_to_update(self, local_server): """ .. _items_to_update: Returns the list of references to atomic documents that have changed since the last synchronization. Defaults to a one-element list containing a reference to self. .. note:: Override this method if this document has children documents. """ items = [] last_sync = local_server.last_sync for item in self.all_synced_documents(local_server=local_server): if last_sync is None or item.last_modification is None or last_sync < item.last_modification: items.append(item) return items def items_to_delete(self, local_server): """ .. _items_to_delete: Returns the list of references to atomic documents that have been deleted since the last synchronization. This method will also automatically check for any deleted children documents (no need to override as long as ``top_level_document`` is overridden). """ items = [] last_sync = local_server.last_sync for obj in DeletedSyncableDocument.objects.no_dereference().filter( top_level_document=self.top_level_syncable_document()): if last_sync is None or obj.date is None or last_sync < obj.date: items.append(obj.document) return items def items_to_sync(self, local_server=None): """ Returns a dictionary ``dict`` with two keys: * ``dict['update']`` contains the results of the items_to_update_ method; * ``dict['delete']`` contains the results of the items_to_delete_ method. .. todo:: Remove items that are in the ``delete`` list from the ``update`` list. """ items = {} items['update'] = self.items_to_update(local_server=local_server) items['delete'] = self.items_to_delete(local_server=local_server) ## We should do some cleanup at this point, in particular remove deletable items from 'update' list. return items def __unicode__(self): return "Document with class %s and id %s" % (self.__class__.__name__, str(self.id))
class Activity(ActivityJsonSerializer, CsvSerializer, SyncableDocument): """Describes any kind of user activity.""" meta = {'allow_inheritance': True, 'indexes': ['+date', 'user', 'type']} ### PROPERTIES credentials = db.ReferenceField('UserCredentials') """The credentials under which the user was logged when they performed the action.""" user = db.ReferenceField('User') """The user performing the activity.""" user_username = db.StringField(default="") """The username (= unique id) of the user who has performed the activity""" user_name = db.StringField(default="") """The full name of the user who has performed the activity""" # FIXME this has to be set using the credentials provided date = db.DateTimeField(default=datetime.datetime.now, required=True) """The date at which the activity was performed.""" local_server = db.ReferenceField('LocalServer') """The local server on which the activity has been performed""" local_server_name = db.StringField() """The name of the local server on which the activity has been performed""" type = db.StringField() """ The type of the activity, so we can group the activities by type to better analyse them. This is supposed to be defaulted/initialized in each subclass""" @property def object(self): return None object_title = db.StringField() """ The title of the object associated to this activity. It allows a better comprehension of the activity than the activity_id. This is supposed to be defaulted/initialized in each subclass """ @property def url(self, _external=False): print("Activity (%s) url: %s" % (self._cls, self.id)) return url_for("activity.get_activity", activity_id=self.id, _external=_external) @property def local_server_id(self): return self.local_server.id if self.local_server is not None else "" @property def user_id(self): return self.user.id if self.user is not None else "" def clean(self): super(Activity, self).clean() if self.object and not isinstance(self.object, DBRef): self.object_title = getattr(self.object, 'title', None) if self.credentials and not isinstance(self.credentials, DBRef): self.user = self.credentials.user self.user_username = self.credentials.username self.local_server = self.credentials.local_server if self.user and not isinstance(self.user, DBRef): self.user_name = self.user.full_name if self.local_server and not isinstance(self.local_server, DBRef): self.local_server_name = self.local_server.name @classmethod def field_names_header_for_csv(cls): return [ 'Koombook id', 'Koombook name', 'User id', 'User username', 'User full name', 'Date - Time', 'Action type', 'Object id', 'Object title', 'Object-specific data' ] def top_level_syncable_document(self): return self.user def all_synced_documents(self, local_server=None): if self.object and not isinstance(self.object, DBRef): if not local_server.syncs_document(self.object): return [] return super(Activity, self).all_synced_documents(local_server=local_server) def __unicode__(self): return "Activity with type %s for user %s" % (self.type, self.user) def get_field_names_for_csv(self): """ this method gives the fields to export as csv row, in a chosen order """ return [ 'local_server_id', 'local_server_name', 'user_id', 'user_username', 'user_name', 'date', 'type', 'object', 'object_title' ]
class LocalServer(LocalServerJsonSerializer, SyncableDocument): """ .. _LocalServer: This collection contains the list of all central servers connected to the current central server. """ ### PROPERTIES name = db.StringField() key = db.StringField(unique=True) secret = db.StringField() synced_tracks = db.ListField( db.ReferenceField('Track', reverse_delete_rule=PULL)) @property def synced_users(self): from MookAPI.services import user_credentials return [ creds.user for creds in user_credentials.find(local_server=self) ] @property def synced_documents(self): from MookAPI.services import static_pages documents = [] documents.extend(self.synced_tracks) documents.extend(self.synced_users) documents.extend(static_pages.all()) return documents last_sync = db.DateTimeField() @property def url(self, _external=False): return url_for("local_servers.get_local_server", local_server_id=self.id, _external=_external) def syncs_document(self, document): top_level_document = document.top_level_syncable_document() return top_level_document in self.synced_documents def get_sync_list(self): updates = [] deletes = [] updates.extend(self.items_to_update(local_server=self)) for document in self.synced_documents: updates.extend(document.items_to_update(local_server=self)) deletes.extend(document.items_to_delete(local_server=self)) return dict( updates=unique(updates), deletes= deletes # 'deletes' contains (unhashable) DBRef objects, can't apply 'unique' to it ) def reset(self): self.last_sync = None def __unicode__(self): return "%s [%s]" % (self.name, self.key) @staticmethod def hash_secret(secret): """ Return the md5 hash of the secret+salt """ return bcrypt.encrypt(secret) def verify_secret(self, secret): return bcrypt.verify(secret, self.secret)
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 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 ResourceHierarchy(ResourceHierarchyJsonSerializer, SyncableDocument): """ .. _ResourceHierarchy: An abstract class that can describe a Lesson_, a Skill_ or a Track_. """ meta = {'allow_inheritance': True, 'abstract': True} ### PROPERTIES is_published = db.BooleanField(default=True) """Whether the resource should appear on the platform""" title = db.StringField(required=True) """The title of the hierarchy level.""" slug = db.StringField(unique=True) """A human-readable unique identifier for the hierarchy level.""" description = db.StringField() """A text describing the content of the resources in this hierarchy level.""" order = db.IntField() """The order of the hierarchy amongst its siblings.""" date = db.DateTimeField(default=datetime.datetime.now, required=True) """The date the hierarchy level was created.""" def is_validated_by_user(self, user): """Whether the user validated the hierarchy level based on their activity.""" ## Override this method in each subclass return False @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) def user_progress(self, user): """ How many sub-units in this level have been validated (current) and how many are there in total (max). Returns a dictionary with format: {'current': Int, 'max': Int} """ ## Override this method in each subclass return {'current': 0, 'max': 0} @property def progress(self): try: verify_jwt() except: pass if not current_user: return None return self.user_progress(current_user.user) ### METHODS 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): self._set_slug() super(ResourceHierarchy, self).clean() def __unicode__(self): return self.title @property def hierarchy(self): """Returns an array of the breadcrumbs up until the current object.""" return [] def user_analytics(self, user): return dict() def user_info(self, user, analytics=False): info = dict(is_validated=self.is_validated_by_user(user), progress=self.user_progress(user)) if analytics: info['analytics'] = self.user_analytics(user) return info