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'), ]
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'), ]
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'), ]
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'))
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)
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'), ]
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'), ]
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'), ]
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'), ]
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()