Пример #1
0
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.")
Пример #2
0
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)
Пример #3
0
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))
Пример #4
0
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'
        ]
Пример #5
0
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)
Пример #6
0
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
Пример #8
0
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