Example #1
0
class ContentActivity(Activity):
    """
    Content activities simply show a content to the students.
    """
    class Meta:
        verbose_name = _('content activity')
        verbose_name_plural = _('content activities')

    body = models.StreamField([
        #('paragraph', blocks.RichTextBlock()),
        #('page', blocks.PageChooserBlock()),
        #('file_list', blocks.ListBlock(blocks.DocumentChooserBlock())),
        #('code', blocks.CodeBlock()),
    ])

    # Wagtail admin
    content_panels = Activity.content_panels + [
        panels.StreamFieldPanel('body'),
    ]
Example #2
0
class Lesson(models.Page):
    """
    A single lesson in an ordered list.
    """

    class Meta:
        verbose_name = _('Lesson')
        verbose_name_plural = _('Lessons')

    body = models.StreamField([
        ('paragraph', blocks.RichTextBlock()),
    ],
        blank=True,
        null=True
    )

    date = delegate_to('lesson')
    calendar = property(lambda x: x.get_parent())

    def save(self, *args, **kwargs):
        lesson = getattr(self, '_created_for_lesson', None)
        if self.pk is None and lesson is None:
            calendar = lesson.calendar
            ordering = calendar.info.values_list('sort_order', flat=True)
            calendar.lessons.add(Lesson(
                title=self.title,
                page=self,
                sort_order=max(ordering) + 1,
            ))
            calendar.save()

    # Wagtail admin
    parent_page_types = ['courses.Calendar']
    subpage_types = []
    content_panels = models.Page.content_panels + [
        panels.StreamFieldPanel('body'),
    ]
Example #3
0
class LessonPage(models.CodeschoolPage):
    """
    A single lesson in an ordered list.
    """
    class Meta:
        verbose_name = _('Lesson')
        verbose_name_plural = _('Lessons')

    body = models.StreamField([
        ('paragraph', blocks.RichTextBlock()),
    ],
                              blank=True,
                              null=True)

    description = delegate_to('lesson')
    date = delegate_to('lesson')
    calendar = property(lambda x: x.get_parent())

    # Wagtail admin
    parent_page_types = ['cs_core.CalendarPage']
    subpage_types = []
    content_panels = models.CodeschoolPage.content_panels + [
        panels.StreamFieldPanel('body'),
    ]
Example #4
0
class FormQuestion(Question):
    """
    FormQuestion's defines a question with multiple fields that can be
    naturally represented in a web form. A FormQuestion thus expect a response
    """
    form_data = models.StreamField(
        [
            #('numeric', blocks.NumericAnswerBlock()),
            #('boolean', blocks.BooleanAnswerBlock()),
            #('string', blocks.StringAnswerBlock()),
            #('date', blocks.DateAnswerBlock()),
            #('file', blocks.TextFileAnswerBlock()),
            #('script', blocks.ScriptGraderAnswerBlock()),
            (
                'content',
                blocks.StreamBlock([
                    ('description', blocks.RichTextBlock()),
                    #('code', blocks.CodeBlock()),
                    #('markdown', blocks.MarkdownBlock()),
                    ('image', blocks.ImageChooserBlock()),
                    ('document', blocks.DocumentChooserBlock()),
                    ('page', blocks.PageChooserBlock()),
                ])),
        ],
        verbose_name=_('Fields'),
        help_text=_(
            'You can insert different types of fields for the student answers. '
            'This works as a simple form that accepts any combination of the'
            'different types of answer fields.'))

    def clean(self):
        super().clean()
        data = list(self.form_values())
        if not data:
            raise ValidationError({
                'body':
                _('At least one form entry is necessary.'),
            })

        # Test if ref keys are unique: when we implement this correctly, there
        # will have a 1 in 10**19 chance of collision. So we wouldn't expect
        # this to ever fail.
        ref_set = {value['ref'] for value in data}
        if len(ref_set) < len(data):
            raise ValidationError({
                'body':
                _('Answer block ref keys are not unique.'),
            })

    def submit(self, raw_data=None, **kwargs):
        # Transform all values received as strings and normalize them to the
        # correct python objects.
        if raw_data is not None:
            response_data = {}
            children = self.stream_children_map()
            for key, value in raw_data.items():
                child = children[key]
                block = child.block
                blk_value = child.value
                response_data[key] = block.normalize_response(blk_value, value)
            kwargs['response_data'] = response_data
        return super().submit(**kwargs)

    def stream_children(self):
        """
        Iterates over AnswerBlock based stream children.
        """

        return (blk for blk in self.form_data if blk.block_type != 'content')

    def stream_items(self):
        """
        Iterates over pairs of (key, stream_child) objects.
        """

        return ((blk.value['ref'], blk) for blk in self.stream_children())

    def form_values(self):
        """
        Iterate over all values associated with the question AnswerBlocks.
        """

        return (blk.value for blk in self.stream_children())

    def form_blocks(self):
        """
        Iterate over all AnswerBlock instances in the question.
        """

        return (blk.block for blk in self.stream_children())

    def stream_child(self, key, default=NOT_PROVIDED):
        """
        Return the StreamChild instance associated with the given key.

        If key is not found, return the default value, if given, or raises a
        KeyError.
        """

        for block in self.form_data:
            if block.block_type != 'content' and block.value['ref'] == key:
                return block

        if default is NOT_PROVIDED:
            raise KeyError(key)
        return default

    def form_block(self, key, default=NOT_PROVIDED):
        """
        Return the AnswerBlock instance for the given key.
        """

        try:
            return self.stream_child(key).block
        except KeyError:
            if default is NOT_PROVIDED:
                raise
            return default

    def form_value(self, ref, default=NOT_PROVIDED):
        """
        Return the form data for the given key.
        """

        try:
            return self.stream_child(key).value
        except KeyError:
            if default is NOT_PROVIDED:
                raise
            return default

    def stream_children_map(self):
        """
        Return a dictionary mapping keys to the corresponding stream values.
        """

        return {blk.value['ref']: blk for blk in self.form_data}

    # Serving pages and routing
    @srvice.route(r'^submit-response/$')
    def route_submit(self, client, fileData=None, **kwargs):
        """
        Handles student responses via AJAX and a srvice program.
        """

        data = {}
        file_data = fileData or {}
        for key, value in kwargs.items():
            if key.startswith('response__') and value:
                key = key[10:]  # strips the heading 'response__'
                data[key] = value

                # We check the stream child type and take additional measures
                # depending on the type
                stream_child = self.stream_child(key)
                if stream_child.block_type == 'file':
                    data[key] = file_data.get(value[0], '')

        super().route_submit(
            client=client,
            response_context=kwargs['response_context'],
            raw_data=data,
        )

    def get_response_form(self):
        block = self.form_data[0]
        return block.render()

    def get_context(self, request, *args, **kwargs):
        context = super().get_context(request, *args, **kwargs)
        context['form'] = self.get_response_form()
        return context

    # Wagtail admin
    content_panels = Question.content_panels[:]
    content_panels.insert(-1, panels.StreamFieldPanel('form_data'))
Example #5
0
class Question(models.DecoupledAdminPage, mixins.ShortDescriptionPage,
               Activity):
    """
    Base abstract class for all question types.
    """
    class Meta:
        abstract = True
        permissions = (("download_question", "Can download question files"), )

    body = models.StreamField(
        QUESTION_BODY_BLOCKS,
        blank=True,
        null=True,
        verbose_name=_('Question description'),
        help_text=_(
            'Describe what the question is asking and how should the students '
            'answer it as clearly as possible. Good questions should not be '
            'ambiguous.'),
    )
    comments = models.RichTextField(
        _('Comments'),
        blank=True,
        help_text=_('(Optional) Any private information that you want to '
                    'associate to the question page.'))
    import_file = models.FileField(
        _('import question'),
        null=True,
        blank=True,
        upload_to='question-imports',
        help_text=_(
            'Fill missing fields from question file. You can safely leave this '
            'blank and manually insert all question fields.'))

    def get_navbar(self, user):
        """
        Returns the navbar for the given question.
        """

        from .components import navbar_question

        return navbar_question(self, user)

    # Serve pages
    def get_submission_kwargs(self, request, kwargs):
        return {}

    def get_context(self, request, *args, **kwargs):
        context = dict(super().get_context(request, *args, **kwargs),
                       question=self,
                       form_name='response-form',
                       navbar=self.get_navbar(request.user))
        return context

    #
    # Routes
    #
    def serve_ajax_submission(self, client, **kwargs):
        """
        Serve AJAX request for a question submission.
        """
        kwargs = self.get_submission_kwargs(client.request, kwargs)
        submission = self.submit(client.request, **kwargs)
        if submission.recycled:
            client.dialog(html='You already submitted this response!')
        elif self._meta.instant_feedback:
            feedback = submission.auto_feedback()
            data = feedback.render_message()
            client.dialog(html=data)
        else:
            client.dialog(html='Your submission is on the correction queue!')

    @srvice.route(r'^submit-response.api/$', name='submit-ajax')
    def route_ajax_submission(self, client, **kwargs):
        return self.serve_ajax_submission(client, **kwargs)
Example #6
0
class Question(models.RoutablePageMixin, Activity):
    """
    Base abstract class for all question types.
    """

    class Meta:
        abstract = True
        permissions = (("download_question", "Can download question files"),)

    stem = models.StreamField(
        QUESTION_STEM_BLOCKS,
        blank=True,
        null=True,
        verbose_name=_('Question description'),
        help_text=_(
            'Describe what the question is asking and how should the students '
            'answer it as clearly as possible. Good questions should not be '
            'ambiguous.'
        ),
    )
    author_name = models.CharField(
        _('Author\'s name'),
        max_length=100,
        blank=True,
        help_text=_(
            'The author\'s name, if not the same user as the question owner.'
        ),
    )
    comments = models.RichTextField(
        _('Comments'),
        blank=True,
        help_text=_('(Optional) Any private information that you want to '
                    'associate to the question page.')
    )

    @property
    def long_description(self):
        return str(self.stem)

    # Permission control
    def can_edit(self, user):
        """Only the owner of the question can edit it"""
        if user is None or self.owner is None:
            return False
        return self.owner.pk == user.pk

    def can_create(self, user):
        """You have to be the teacher of a course in order to create new
        questions."""

        return not user.courses_as_teacher.empty()

    # Serving pages and routing
    @srvice.route(r'^submit-response/$')
    def respond_route(self, client, **kwargs):
        """
        Handles student responses via AJAX and a srvice program.
        """

        raise NotImplementedError

    @models.route(r'^stats/$')
    def stats_route(self, request, **kwargs):
        """
        Shows the stats for each question.
        """

        data = """<dl>
            <dt>Name<dt><dd>{name}<dd>
            <dt>Best grade<dt><dd>{best}<dd>
            <dt>Responses<dt><dd>{n_responses}<dd>
            <dt>Response items<dt><dd>{n_response_items}<dd>
            <dt>Correct responses<dt><dd>{n_correct}<dd>
            <dt>Mean grade responses<dt><dd>{mean}<dd>
            <dt>Context id</dt><dd>{context_id}</dd>
        </dl>
        """.format(
            context_id=self.default_context.id,
            name=self.title,
            best=self.best_final_grade(),
            mean=self.mean_final_grade(),
            n_correct=self.correct_responses().count(),
            n_response_items=self.response_items().count(),
            n_responses=self.responses.count(),
        )

        # Renders content
        context = {'content_body': data,
                   'content_text': 'Stats'}
        return render(request, 'base.jinja2', context)

    @models.route(r'^responses/')
    def response_list_route(self, request):
        """
        Renders a list of responses
        """

        user = request.user
        context = self.get_context(request)
        items = self.response_items(user=user, context='any')
        items = (x.get_real_instance() for x in items)
        context.update(
            question=self,
            object_list=items,
        )
        return render(request, 'cs_questions/response-list.jinja2', context)

    # Wagtail admin
    parent_page_types = [
        'cs_questions.QuestionList',
        'cs_core.Discipline',
        'cs_core.Faculty'
    ]
    content_panels = Activity.content_panels + [
        panels.StreamFieldPanel('stem'),
        panels.MultiFieldPanel([
            panels.FieldPanel('author_name'),
            panels.FieldPanel('comments'),
        ], heading=_('Optional information'),
           classname='collapsible collapsed'),
    ]
Example #7
0
class Activity(models.CopyMixin,
               models.ShortDescribablePage):
    """
    Represents a gradable activity inside a course. Activities may not have an
    explicit grade, but yet may provide points to the students via the
    gamefication features of Codeschool.

    Activities can be scheduled to be done in the class or as a homework
    assignment.

    Each concrete activity is represented by a different subclass.
    """

    class Meta:
        abstract = True
        verbose_name = _('activity')
        verbose_name_plural = _('activities')

    icon_src = models.CharField(
        _('activity icon'),
        max_length=50,
        blank=True,
        help_text=_(
            'Optional icon name that can be used to personalize the activity. '
            'Material icons are available by using the "material:" namespace '
            'as in "material:menu".'),
    )
    resources = models.StreamField(RESOURCE_BLOCKS)
    objects = models.PageManager.from_queryset(ActivityQueryset)()

    # References
    @property
    def course(self):
        """
        Points to the course this activity belongs to.
        """

        return getattr(self.get_parent(), 'course_instance', None)

    @course.setter
    def course(self, value):
        self.set_parent(value)

    @property
    def default_context(self):
        """
        Return the default context.
        """

        cls = apps.get_model('cs_core', 'ResponseContext')
        return cls.objects.get_or_create(activity_id=self.id,
                                         name='default')[0]

    #: Define the default material icon used in conjunction with instances of
    #: the activity class.
    default_material_icon = 'help'

    #: The response class associated with the given activity.
    response_class = None

    @property
    def material_icon(self):
        """
        The material icon used in conjunction with the activity.
        """

        if self.icon_src.startswith('material:'):
            return self.icon_src[9:]
        return self.default_material_icon

    @property
    def icon_html(self):
        """
        A string of HTML source that points to the icon element fo the activity.
        """

        return '<i class="material-icon">%s</i>' % self.material_icon

    # We define the optional user and context objects to bind responses to the
    # question object. These are not saved into the database, but are rather
    # used as default values to fill-in in the response objects. These objects
    # can be bound at init time or using the bind() method.
    @bound_property
    def user(self):
        return getattr(self, '_user', None)

    @user.setter
    def user(self, value):
        if isinstance(value, int):
            value = models.User.objects.get(pk=value)
        elif isinstance(value, str):
            value = models.User.objects.get(username=value)
        if not isinstance(value, (models.User, type(None))):
            raise TypeError('invalid user: %r' % value)
        self._user = value

    @bound_property
    def response_context(self):
        try:
            return self._context
        except AttributeError:
            return self.default_context

    @response_context.setter
    def context(self, value):
        if isinstance(value, int):
            value = ResponseContext.objects.get(pk=int)
        if not isinstance(value, (models.ResponseContext, type(None))):
            raise TypeError('invalid context: %r' % value)
        self._context = value

    @property
    def is_user_bound(self):
        return self.user is not None

    @property
    def is_context_bound(self):
        return self.context is not None

    def __init__(self, *args, **kwargs):
        # Get parent page from initialization
        course = kwargs.pop('course', None)
        discipline = kwargs.pop('discipline', None)
        user = kwargs.pop('user', None)
        if sum(1 for x in [course, discipline, user] if x is not None) >= 2:
            raise TypeError(
                'Can only use one of course, discipline or user arguments.'
            )
        super().__init__(*args, **kwargs)
        parent = course or discipline or user
        if parent is not None:
            self.set_parent(parent)

    def bind(self, *args, **kwargs):
        """
        Temporary binds objects to activity.

        This is useful to bind a question instance to some specific user or
        response context. These changes are not persisted on the database and
        are just a convenience for using other methods.

        This method accept a single positional argument for passing a request
        object. Any number of keyword arguments might be given for each
        registered binding properties for the object. For convenience, invalid
        arguments are just ignored.
        """

        if args:
            request = args[0]
            kwargs.setdefault('user', request.user)

        # We check in the class if each item is a bound_property. If so, we
        # save its value with the given data.
        cls = self.__class__
        for k, v in kwargs.items():
            if isinstance(getattr(cls, k, None), bound_property):
                setattr(self, k, v)

        # Return self so this method can be chained.
        return self

    #
    # Response control
    #
    def get_response(self, user=None, context=None):
        """
        Get the response associated with given user and context.

        If no user and context is given, use the bound values.
        """

        user = user or self.user
        context = context or self.context
        Response = apps.get_model('cs_core', 'Response')
        return Response.get_response(user=user, context=context, activity=self)

    def register_response_item(self, *,
                               response_data=None,
                               user=None,
                               context=None,
                               autograde=False,
                               recycle=False,
                               _kwargs=None):
        """
        Create a new response item object for the given question and saves it on
        the database.

        Args:
            user:
                The user who submitted the response.
            context:
                The context object associated with the response. Uses the
                default context, if not given.
            autograde:
                If true, calls the autograde() method in the response to
                give the automatic gradings.
            recycle:
                If true, recycle response items with the same content as the
                submission. It checks if a different submission exists with the
                same response_data attribute. If so, it returns this submission
                instead of saving a new one in the database.
            _kwargs:
                Additional arguments that should be passed to the
                response item constructor. This should only be used by
                subclasses to pass extra arguments to the super method.
        """

        response_item_class = self.response_item_class

        # Check if the user and context are given
        user = user or self.user
        context = context or self.context
        if user is None:
            raise TypeError('a valid user is required')

        # We compute the hash and compare it with values on the database
        # if recycle is enabled
        response_hash = response_item_class.get_response_hash(response_data)
        response = None
        recycled = False
        if recycle:
            recyclable = response_item_class.objects.filter(
                activity=self,
                context=context,
                response_hash=response_hash,
            )
            for pk, value in recyclable.values_list('id', 'response_data'):
                if value == response_data:
                    response = recyclable.get(pk=pk)
                    recycled = True

        # Proceed if no response was created
        if response is None:
            response = self.response_item_class(
                user=user,
                context=context,
                activity=self,
                response_hash=response_hash,
                response_data=response_data,
                **(_kwargs or {})
            )

        # If the context owner is not the current activity, we have to take
        # additional steps to finalize the response_item to a proper state.
        if context.activity_id != self.id:
            context.activity.process_response_item(response, recycled)

        # Finalize response item
        if autograde:
            response.autograde()
        else:
            response.save()
        return response

    def process_response_item(self, response, recycled=False):
        """
        Process this response item generated by other activities using a context
        that you own.

        This might happen in compound activities like quizzes, in which the
        response to a question uses a context own by a quiz object. This
        function allows the container object to take any additional action
        after the response is created.
        """

    def has_response(self, user=None, context=None):
        """
        Return True if the user has responded to the activity.
        """

        response = self.get_response(user, context)
        return response.response_items.count() >= 1

    def correct_responses(self, context=None):
        """
        Return a queryset with all correct responses for the given context.
        """

        done = apps.get_model('cs_core', 'ResponseItem').STATUS_DONE
        items = self.response_items(context, status=done)
        return items.filter(given_grade=100)

    # def get_user_response(self, user, method='first'):
    #     """
    #     Return some response given by the user or None if the user has not
    #     responded.
    #
    #     Allowed methods:
    #         unique:
    #             Expects that response is unique and return it (or None).
    #         any:
    #             Return a random user response.
    #         first:
    #             Return the first response given by the user.
    #         last:
    #             Return the last response given by the user.
    #         best:
    #             Return the response with the best final grade.
    #         worst:
    #             Return the response with the worst final grade.
    #         best-given:
    #             Return the response with the best given grade.
    #         worst-given:
    #             Return the response with the worst given grade.
    #
    #     """
    #
    #     responses = self.responses.filter(user=user)
    #     first = lambda x: x.select_subclasses().first()
    #
    #     if method == 'unique':
    #         N = self.responses.count()
    #         if N == 0:
    #             return None
    #         elif N == 1:
    #             return response.select_subclasses().first()
    #         else:
    #             raise ValueError(
    #                 'more than one response found for user %r' % user.username
    #             )
    #     elif method == 'any':
    #         return first(responses)
    #     elif method == 'first':
    #         return first(responses.order_by('created'))
    #     elif method == 'last':
    #         return first(responses.order_by('-created'))
    #     elif method in ['best', 'worst', 'best-given', 'worst-given']:
    #         raise NotImplementedError(
    #             'method = %r is not implemented yet' % method
    #         )
    #     else:
    #         raise ValueError('invalid method: %r' % method)
    #
    # def autograde_all(self, force=False, context=None):
    #     """
    #     Grade all responses that had not been graded yet.
    #
    #     This function may take a while to run, locking the server. Maybe it is
    #     a good idea to run it as a task or in a separate thread.
    #
    #     Args:
    #         force (boolean):
    #             If True, forces the response to be re-graded.
    #     """
    #
    #     # Run autograde on each responses
    #     for response in responses:
    #         response.autograde(force=force)
    #
    # def select_users(self):
    #     """
    #     Return a queryset with all users that responded to the activity.
    #     """
    #
    #     user_ids = self.responses.values_list('user', flat=True).distinct()
    #     users = models.User.objects.filter(id__in=user_ids)
    #     return users
    #
    # def get_grades(self, users=None):
    #     """
    #     Return a dictionary mapping each user to their respective grade in the
    #     activity.
    #
    #     If a list of users is given, include only the users in this list.
    #     """
    #
    #     if users is None:
    #         users = self.select_users()
    #
    #     grades = {}
    #     for user in users:
    #         grade = self.get_user_grade(user)
    #         grades[user] = grade
    #     return grades

    #
    # Statistics
    #
    def response_items(self, context=None, status=None, user=None):
        """
        Return a queryset with all response items associated with the given
        activity.

        Can filter by context, status and user
        """

        items = self.response_item_class.objects
        queryset = items.filter(response__activity_id=self.id)

        # Filter context
        if context != 'any':
            context = context or self.context
            queryset = queryset.filter(response__context_id=context.id)

        # Filter user
        user = user or self.user
        if user:
            queryset = queryset.filter(response__user_id=user.id)

        # Filter by status
        if status:
            queryset = queryset.filter(status=status)

        return queryset

    def _stats(self, attr, context, by_item=False):
        if by_item:
            items = self.response_items(context, self.STATUS_DONE)
            values_list = items.values_list(attr, flat=True)
            return Statistics(attr, values_list)
        else:
            if context == 'any':
                items = self.responses.all()
            else:
                context = context or self.context
                items = self.responses.all().filter(context=context)
            return Statistics(attr, items.values_list(attr, flat=True))

    def best_final_grade(self, context=None):
        """
        Return the best final grade given for this activity.
        """

        return self._stats('final_grade', context).max()

    def best_given_grade(self, context=None):
        """
        Return the best grade given for this activity before applying any
        penalties and bonuses.
        """

        return self._stats('given_grade', context).min()

    def mean_final_grade(self, context=None, by_item=False):
        """
        Return the average value for the final grade for this activity.

        If by_item is True, compute the average over all response items instead
        of using the responses for each student.
        """

        return self._stats('final_grade', context, by_item).mean()

    def mean_given_grade(self, by_item=False):
        """
        Return the average value for the given grade for this activity.
        """

        return self._stats('given_grade', context, by_item).mean()

    #
    # Permission control
    #
    def can_edit(self, user):
        """
        Return True if user has permissions to edit activity.
        """

        return user == self.owner or self.course.can_edit(user)

    def can_view(self, user):
        """
        Return True if user has permission to view activity.
        """

        course = self.course
        return (
            self.can_edit(user) or
            user in course.students.all() or
            user in self.staff.all()
        )

    # Wagtail admin
    subpage_types = []
    parent_page_types = [
        'cs_core.Course',
        'cs_core.LessonPage'
    ]
    content_panels = models.CodeschoolPage.content_panels + [
        panels.MultiFieldPanel([
            panels.RichTextFieldPanel('short_description'),
        ], heading=_('Options')),
    ]
    promote_panels = models.CodeschoolPage.promote_panels + [
        panels.FieldPanel('icon_src')
    ]
    settings_panels = models.CodeschoolPage.settings_panels + [
        panels.StreamFieldPanel('resources'),
    ]
Example #8
0
class Question(models.RoutablePageMixin,
               models.ShortDescriptionPageMixin,
               Activity,
               metaclass=QuestionMeta):
    """
    Base abstract class for all question types.
    """
    class Meta:
        abstract = True
        permissions = (("download_question", "Can download question files"), )

    EXT_TO_METHOD_CONVERSIONS = {'yml': 'yaml'}
    OPTIONAL_IMPORT_FIELDS = [
        'author_name', 'comments', 'score_value', 'star_value'
    ]
    base_form_class = QuestionAdminModelForm

    body = models.StreamField(
        QUESTION_BODY_BLOCKS,
        blank=True,
        null=True,
        verbose_name=_('Question description'),
        help_text=_(
            'Describe what the question is asking and how should the students '
            'answer it as clearly as possible. Good questions should not be '
            'ambiguous.'),
    )
    comments = models.RichTextField(
        _('Comments'),
        blank=True,
        help_text=_('(Optional) Any private information that you want to '
                    'associate to the question page.'))
    import_file = models.FileField(
        _('import question'),
        null=True,
        blank=True,
        upload_to='question-imports',
        help_text=_(
            'Fill missing fields from question file. You can safely leave this '
            'blank and manually insert all question fields.'))
    __imported_data = None

    def load_from_file_data(self, file_data):
        """
        Import content from raw file data.
        """

        fmt = self.loader_format_from_filename(file_data.name)
        self.load_from(file_data, format=fmt)
        self.__imported_data = dict(self.__dict__)

        logger.info('Imported question "%s" from file "%s"' %
                    (self.title, self.import_file.name))

        # We fake POST data after loading data from file in order to make the
        # required fields to validate. This part constructs a dictionary that
        # will be used to feed a fake POST data in the QuestionAdminModelForm
        # instance
        fake_post_data = {
            'title': self.title or _('Untitled'),
            'short_description': self.short_description or _('untitled'),
        }

        for field in self.OPTIONAL_IMPORT_FIELDS:
            if getattr(self, field, None):
                fake_post_data[field] = getattr(self, field)

        base_slug = slugify(fake_post_data['title'])
        auto_generated_slug = self._get_autogenerated_slug(base_slug)
        fake_post_data['slug'] = auto_generated_slug
        return fake_post_data

    def loader_format_from_filename(self, name):
        """
        Returns a string with the loader method from the file extension
        """

        _, ext = os.path.splitext(name)
        ext = ext.lstrip('.')
        return self.EXT_TO_METHOD_CONVERSIONS.get(ext, ext)

    def load_from(self, data, format='yaml'):
        """
        Load data from the given file or string object using the specified
        method.
        """

        try:
            loader = getattr(self, 'load_from_%s' % format)
        except AttributeError:
            raise ValueError('format %r is not implemented' % format)
        return loader(data)

    def full_clean(self, *args, **kwargs):
        if self.__imported_data is not None:
            blacklist = {
                # References
                'id',
                'owner_id',
                'page_ptr_id',
                'content_type_id',

                # Saved fields
                'title',
                'short_description',
                'seo_title',
                'author_name',
                'slug',
                'comments',
                'score_value',
                'stars_value',
                'difficulty',

                # Forbidden fields
                'import_file',

                # Wagtail fields
                'path',
                'depth',
                'url_path',
                'numchild',
                'go_live_at',
                'expire_at',
                'show_in_menus',
                'has_unpublished_changes',
                'latest_revision_created_at',
                'first_published_at',
                'live',
                'expired',
                'locked',
                'search_description',
            }

            data = {
                k: v
                for k, v in self.__imported_data.items()
                if (not k.startswith('_')) and k not in blacklist and v not in
                (None, '')
            }

            for k, v in data.items():
                setattr(self, k, v)

        super().full_clean(*args, **kwargs)

    # Serve pages
    def get_context(self, request, *args, **kwargs):
        return dict(
            super().get_context(request, *args, **kwargs),
            response=self.responses.response_for_request(request),
            question=self,
            form_name='response-form',
        )

    @srvice.route(r'^submit-response/$')
    def route_submit(self, client, **kwargs):
        """
        Handles student responses via AJAX and a srvice program.
        """

        response = self.submit(user=client.user, **kwargs)
        response.autograde()
        data = render_html(response)
        client.dialog(html=data)

    @models.route(r'^submissions/$')
    def route_submissions(self, request, *args, **kwargs):
        submissions = self.submissions.user(request.user).order_by('-created')
        context = self.get_context(request, *args, **kwargs)
        context['submissions'] = submissions

        # Fetch template name from explicit configuration or compute the default
        # value from the class name
        try:
            template = getattr(self, 'template_submissions')
            return render(request, template, context)
        except AttributeError:
            name = self.__class__.__name__.lower()
            if name.endswith('question'):
                name = name[:-8]
            template = 'questions/%s/submissions.jinja2' % name

            try:
                return render(request, template, context)
            except TemplateDoesNotExist:
                raise ImproperlyConfigured(
                    'Model %s must define a template_submissions attribute. '
                    'You  may want to extend this template from '
                    '"questions/submissions.jinja2"' % self.__class__.__name__)

    @models.route(r'^leaderboard/$')
    @models.route(r'^statistics/$')
    @models.route(r'^submissions/$')
    @models.route(r'^social/$')
    def route_page_does_not_exist(self, request):
        return render(
            request, 'base.jinja2', {
                'content_body':
                'The page you are trying to see is not implemented '
                'yet.',
                'content_title':
                'Not implemented',
                'title':
                'Not Implemented'
            })

    # Wagtail admin
    subpage_types = []
    content_panels = models.ShortDescriptionPageMixin.content_panels[:-1] + [
        panels.MultiFieldPanel([
            panels.FieldPanel('import_file'),
            panels.FieldPanel('short_description'),
        ],
                               heading=_('Options')),
        panels.StreamFieldPanel('body'),
        panels.MultiFieldPanel([
            panels.FieldPanel('author_name'),
            panels.FieldPanel('comments'),
        ],
                               heading=_('Optional information'),
                               classname='collapsible collapsed'),
    ]
Example #9
0
class Quiz(Activity):
    """
    A quiz that may contain several different questions.
    """
    class Meta:
        verbose_name = _('quiz activity')
        verbose_name_plural = _('quiz activities')

    body = models.StreamField(
        QUESTION_STEM_BLOCKS,
        blank=True,
        null=True,
        verbose_name=_('Quiz description'),
        help_text=_(
            'This field should contain a text with any instructions, tips, or '
            'information that is relevant to the current quiz. Remember to '
            'explain clearly the rules and what is expected from each student.'
        ),
    )
    language = models.ForeignKey(
        ProgrammingLanguage,
        on_delete=models.SET_NULL,
        blank=True,
        null=True,
        related_name='quizzes',
        verbose_name=_('Programming language'),
        help_text=_(
            'Forces an specific programming language for all programing '
            'related questions. If not given, will accept responses in any '
            'programming language. This has no effect in non-programming '
            'activities.'),
    )

    # Derived attributes
    questions = property(lambda x: [i.question for i in x.quiz_items.all()])
    num_questions = property(lambda x: x.quiz_items.count())

    def add_question(self, question, weight=1.0):
        """
        Add a question to the quiz.
        """

        self.quiz_items.create(question=question, weight=weight)
        item = QuizItem(quiz=self, question=question)
        item.save()
        self.items.append(item)

    def register_response_item(self, *, user=None, context=None, **kwargs):
        """
        Return a response object for the given user.

        For now, users can only have one response.
        """

        # Silently ignore autograde
        kwargs.pop('autograde', None)

        # Quiz responses do not accept any extra parameters in the constructor
        if kwargs:
            param = kwargs.popitem()[0]
            raise TypeError('invalid parameter: %r' % param)

        # We force that quiz responses have a single response_item which is
        # only updated by the process_response_item() method.
        response = self.get_response(user, context)
        if response.items.count() != 0:
            return response.items.first()

        return super().register_response_item(user=user, context=context)

    def process_response_item(self, response, recycled=False):
        """
        Process a question response and adds a reference to it in the related
        QuizResponseItem.
        """

        # We do not register recycled responses
        if not recycled:
            user = response.user
            context = response.context
            quiz_response_item = self.register_response_item(user=user,
                                                             context=context)
            quiz_response_item.register_response(response)

    # Wagtail admin
    parent_page_types = ['cs_questions.QuizList']
    content_panels = Activity.content_panels + [
        panels.StreamFieldPanel('body'),
        panels.InlinePanel('quiz_items', label=_('Questions')),
    ]
    settings_panels = Activity.settings_panels + [
        panels.FieldPanel('language'),
    ]
Example #10
0
class ResponseContext(models.PolymorphicModel):
    """
    Define a different context for a response object.

    The context group responses into explicit groups and may also be used to
    define additional constraints on the correct answers.
    """
    class Meta:
        unique_together = [('activity', 'name')]

    # Basic
    activity = models.ParentalKey(
        'wagtailcore.Page',
        related_name='contexts',
    )
    name = models.CharField(_('name'),
                            max_length=140,
                            blank=True,
                            help_text=_('A unique identifier.'))
    description = models.RichTextField(
        _('description'),
        blank=True,
    )

    # Grading and submissions
    grading_method = models.ForeignKey(
        'cs_core.GradingMethod',
        on_delete=models.SET_DEFAULT,
        default=grading_method_best,
        blank=True,
        help_text=_('Choose the strategy for grading this activity.'))
    single_submission = models.BooleanField(
        _('single submission'),
        default=False,
        help_text=_(
            'If set, students will be allowed to send only a single response.',
        ),
    )

    # Feedback
    delayed_feedback = models.BooleanField(
        _('delayed feedback'),
        default=False,
        help_text=_(
            'If set, students will be only be able to see the feedback after '
            'the activity expires its deadline.'))

    # Deadlines
    deadline = models.DateTimeField(
        _('deadline'),
        blank=True,
        null=True,
    )
    hard_deadline = models.DateTimeField(
        _('hard deadline'),
        blank=True,
        null=True,
        help_text=_(
            'If set, responses submitted after the deadline will be accepted '
            'with a penalty.'))
    delay_penalty = models.DecimalField(
        _('delay penalty'),
        default=25,
        decimal_places=2,
        max_digits=6,
        help_text=_(
            'Sets the percentage of the total grade that will be lost due to '
            'delayed responses.'),
    )

    # Programming languages/formats
    format = models.ForeignKey(
        'cs_core.FileFormat',
        blank=True,
        null=True,
        help_text=_(
            'Defines the required file format or programming language for '
            'student responses, when applicable.'))

    # Extra constraints and resources
    constraints = models.StreamField([], default=[])
    resources = models.StreamField([], default=[])

    def clean(self):
        if not isinstance(self.activity, Activity):
            return ValidationError({
                'parent':
                _('Parent is not an Activity subclass'),
            })
        super().clean()