class TimeSlot(models.Model): """ Represents the weekly time slot that can be assigned to lessons for a given course. """ class Meta: ordering = ('weekday', 'start') MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY = range(7) WEEKDAY_CHOICES = [(MONDAY, _('Monday')), (TUESDAY, _('Tuesday')), (WEDNESDAY, _('Wednesday')), (THURSDAY, _('Thursday')), (FRIDAY, _('Friday')), (SATURDAY, _('Saturday')), (SUNDAY, _('Sunday'))] course = models.ParentalKey('Course', related_name='time_slots') weekday = models.IntegerField( _('weekday'), choices=WEEKDAY_CHOICES, help_text=_('Day of the week in which this class takes place.')) start = models.TimeField( _('start'), blank=True, null=True, help_text=_('The time in which the class starts.'), ) end = models.TimeField( _('ends'), blank=True, null=True, help_text=_('The time in which the class ends.'), ) room = models.CharField( _('classroom'), max_length=100, blank=True, help_text=_('Name for the room in which this class takes place.'), ) # Wagtail admin panels = [ panels.FieldRowPanel([ panels.FieldPanel('weekday', classname='col6'), panels.FieldPanel('room', classname='col6'), ]), panels.FieldRowPanel([ panels.FieldPanel('start', classname='col6'), panels.FieldPanel('end', classname='col6'), ]), ]
class QuizItem(models.Orderable): """ A question in a quiz. """ quiz = models.ParentalKey( 'cs_questions.Quiz', related_name='quiz_items', ) question = models.ForeignKey( 'wagtailcore.Page', related_name='+', ) weight = models.FloatField( _('value'), default=1.0, help_text=_( 'The non-normalized weight of this item in the total quiz grade.'), ) # Wagtail admin panels = [ panels.PageChooserPanel('question', [ 'cs_questions.CodingIoQuestion', 'cs_questions.FormQuestion', ]), panels.FieldPanel('weight'), ]
class Faculty(models.DescribablePage): """ Describes a faculty/department or any institution that is responsible for managing disciplines. """ location_coords = models.CharField( _('coordinates'), max_length=255, blank=True, null=True, help_text=_( 'Latitude and longitude coordinates for the faculty building. The ' 'coordinates are selected from a Google Maps widget.'), ) @property def courses(self): return apps.get_model( 'cs_core', 'Course').objects.filter(path__startswith=self.path) # Wagtail admin parent_page_types = ['wagtailcore.Page'] subpage_types = None subpage_types = ['Discipline'] content_panels = models.DescribablePage.content_panels + [ panels.MultiFieldPanel([ panels.FieldPanel('location_coords', classname="gmap"), ], heading=_('Location')), ]
class LessonInfo(models.Orderable): """ Intermediate model between a LessonPage and a Calendar to make it orderable. """ class Meta: verbose_name = _('Lesson') verbose_name_plural = _('Lessons') calendar = models.ParentalKey( Calendar, on_delete=models.CASCADE, null=True, blank=True, related_name='info', ) page = models.OneToOneField( Lesson, null=True, blank=True, related_name='info', ) title = models.TextField( _('title'), help_text=_('A brief description for the lesson.'), ) date = models.DateField( _('date'), null=True, blank=True, help_text=_('Date scheduled for this lesson.'), ) def save(self, *args, **kwargs): if self.pk is None and self.page is None: self.page = lesson_page = Lesson( title=self.title, slug=slugify(self.title), ) lesson_page._created_for_lesson = self self.calendar.add_child(instance=lesson_page) super().save(*args, **kwargs) panels = [ panels.FieldPanel('title'), panels.FieldPanel('date'), ]
class ScrumProject(models.RoutablePageMixin, models.Page): """ A simple scrum project. """ description = models.RichTextField() members = models.ManyToManyField(models.User) workday_duration = models.IntegerField(default=2) @property def backlog_tasks(self): return self.tasks.filter(status=Task.STATUS_BACKLOG) # Public functions def finish_date(self): """ Return the finish date for the last sprint. """ try: return self.sprints.order_by('due_date').last().due_date except Sprint.DoesNotExist: return now() # Serving pages @models.route(r'^sprints/new/$') def serve_new_sprint(self, request): return serve_new_sprint(request, self) @models.route(r'^sprints/(?P<id>[0-9]+)/$') def serve_view_sprint(self, request, id=None, *args, **kwargs): print(args) print(kwargs) sprint = get_object_or_404(Sprint, id=id) return serve_view_sprint(request, self, sprint) @models.route(r'^sprints/$') def serve_list_sprint(self, request, *args, **kwargs): return serve_list_sprints(request, self) # Wagtail specific template = 'scrum/project.jinja2' content_panels = models.Page.content_panels + [ panels.FieldPanel('description'), panels.FieldPanel('workday_duration'), ]
class QuestionAdmin(WagtailAdmin): class Meta: model = models.Question abstract = True subpage_types = [] content_panels = \ ShortDescriptionPage.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 LoginSettings(BaseSetting): username_as_school_id = models.BooleanField( default=False, help_text=_( 'If true, force the username be equal to the school id for all ' 'student accounts.')) school_id_regex = models.TextField( default='', blank=True, help_text=_( 'A regular expression for matching valid school ids. If blank, no' 'check will be performed on the validity of the given school ids'), ) panels = [ panels.MultiFieldPanel([ panels.FieldPanel('username_as_school_id'), panels.FieldPanel('school_id_regex'), ], heading=_('School id configuration')) ]
class QuestionAdmin(ShortDecriptionAdmin): class Meta: model = models.Question abstract = True subpage_types = [] content_panels = [ ..., # Main description panels.StreamFieldPanel('body'), # Options panels.MultiFieldPanel([ panels.FieldPanel('author_name'), panels.FieldPanel('comments'), ], heading=_('Optional information'), classname='collapsible collapsed'), ]
class CodingIoQuestionAdmin(QuestionAdmin): class Meta: model = models.CodingIoQuestion content_panels = QuestionAdmin.content_panels[:] content_panels.insert( -1, panels.MultiFieldPanel([ panels.FieldPanel('num_pre_tests'), panels.FieldPanel('pre_tests_source'), ], heading=_('Pre-tests definitions'))) content_panels.insert( -1, panels.MultiFieldPanel([ panels.FieldPanel('num_post_tests'), panels.FieldPanel('post_tests_source'), ], heading=_('Post-tests definitions'))) content_panels.insert( -1, panels.InlinePanel('answers', label=_('Answer keys'))) settings_panels = QuestionAdmin.settings_panels + [ panels.MultiFieldPanel([ panels.FieldPanel('language'), panels.FieldPanel('timeout'), ], heading=_('Options')) ]
class Choice(models.Orderable): question = models.ParentalKey(MultipleChoiceQuestion, related_name='choices') text = models.RichTextField(_('Choice description')) uuid = models.UUIDField(default=uuid.uuid4) value = models.DecimalField( _('Value'), decimal_places=1, max_digits=4, validators=[validators.MinValueValidator(0), validators.MaxValueValidator(100)], help_text=_( 'Grade given for users that choose these option (value=0, for an ' 'incorrect choice and value=100 for a correct one).' ), default=0, ) def __repr__(self): return 'Choice(value=%s, ...)' % self.value panels = [ panels.FieldPanel('text'), panels.FieldPanel('value'), ]
class CodeCarouselItem(models.Orderable): """ A simple state of the code in a SyncCodeActivity. """ activity = models.ParentalKey('cs_core.CodeCarouselActivity', related_name='items') text = models.TextField() timestamp = models.DateTimeField(auto_now=True) # Wagtail admin panels = [ panels.FieldPanel('text', widget=blocks.AceWidget()), ]
class CodeCarouselActivity(Activity): """ In this activity, the students follow a piece of code that someone edit and is automatically updated in all of student machines. It keeps track of all modifications that were saved by the teacher. """ class Meta: verbose_name = _('synchronized code activity') verbose_name_plural = _('synchronized code activities') default_material_icon = 'code' language = models.ForeignKey( 'ProgrammingLanguage', on_delete=models.PROTECT, related_name='sync_code_activities', help_text=_('Chooses the programming language for the activity'), ) @property def last(self): try: return self.items.order_by('timestamp').last() except CodeCarouselItem.DoesNotExist: return None @property def first(self): try: return self.items.order_by('timestamp').first() except CodeCarouselItem.DoesNotExist: return None # Wagtail admin content_panels = models.CodeschoolPage.content_panels + [ panels.MultiFieldPanel([ panels.RichTextFieldPanel('short_description'), panels.FieldPanel('language'), ], heading=_('Options')), panels.InlinePanel('items', label='Items'), ]
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 Profile(UserenaBaseProfile, models.CodeschoolPage): """ Social information about users. """ class Meta: permissions = ( ('student', _('Can access/modify data visible to student\'s')), ('teacher', _('Can access/modify data visible only to Teacher\'s')), ) username = delegate_to('user', True) first_name = delegate_to('user') last_name = delegate_to('user') email = delegate_to('user') @property def short_description(self): return '%s (id: %s)' % (self.get_full_name_or_username(), self.school_id) @property def age(self): if self.date_of_birth is None: return None today = timezone.now().date() return int(round((today - self.date_of_birth).years)) user = models.OneToOneField( models.User, unique=True, blank=True, null=True, on_delete=models.SET_NULL, verbose_name=_('user'), related_name='_profile', ) school_id = models.CharField( _('school id'), help_text=_('Identification number in your school issued id card.'), max_length=50, blank=True, null=True) nickname = models.CharField(max_length=50, blank=True, null=True) phone = models.CharField(max_length=20, blank=True, null=True) gender = models.SmallIntegerField(_('gender'), choices=[(0, _('male')), (1, _('female'))], blank=True, null=True) date_of_birth = models.DateField(_('date of birth'), blank=True, null=True) website = models.URLField(blank=True, null=True) about_me = models.RichTextField(blank=True, null=True) objects = ProfileManager.from_queryset(models.PageManager)() def __init__(self, *args, **kwargs): if 'user' in kwargs and 'id' not in kwargs: kwargs.setdefault('parent_page', profile_root()) super().__init__(*args, **kwargs) if self.pk is None and self.user is not None: user = self.user self.title = self.title or __("%(name)s's profile") % { 'name': user.get_full_name() or user.username } self.slug = self.slug or user.username.replace('.', '-') def __str__(self): if self.user is None: return __('Unbound profile') full_name = self.user.get_full_name() or self.user.username return __('%(name)s\'s profile') % {'name': full_name} def get_full_name_or_username(self): name = self.user.get_full_name() if name: return name else: return self.user.username # Wagtail admin parent_page_types = ['cs_core.ProfileRoot'] content_panels = models.CodeschoolPage.content_panels + [ panels.MultiFieldPanel([ panels.FieldPanel('school_id'), ], heading='Required information'), panels.MultiFieldPanel([ panels.FieldPanel('nickname'), panels.FieldPanel('phone'), panels.FieldPanel('gender'), panels.FieldPanel('date_of_birth'), ], heading=_('Personal Info')), panels.MultiFieldPanel([ panels.FieldPanel('website'), ], heading=_('Web presence')), panels.RichTextFieldPanel('about_me'), ]
class ActivitySection(ScoreBoardMixin, mixins.ShortDescriptionPage, models.Page): """ List of activities. """ class Meta: verbose_name = _('section') verbose_name_plural = _('sections') material_icon = models.CharField( _('Optional icon'), max_length=20, default='help', ) objects = ActivityListManager() @property def activities(self): return [x.specific for x in self.get_children()] # Special template constructors @classmethod def create_subpage(cls, parent=None, **kwargs): """ Create a new ActivitySection using the given keyword arguments under the given parent page. If no parent is chosen, uses the "main-question-list" reference. """ parent = parent or model_reference.load('main-question-list') new = cls(**kwargs) parent.add_child(instance=new) new.save() return new @classmethod def from_template(cls, template, parent=None): """ Creates a new instance from the given template. Valid templates are: basic Very basic beginner IO based problems. First contact with programming. conditionals Simple problems based on if/else flow control. loops Problems that uses for/while loops. functions Problems that uses functions. files Reading and writing files. lists Linear data structures such as lists and arrays. """ try: factory = getattr(cls, '_template_%s' % template) return factory(parent) except AttributeError: raise ValueError('invalid template name: %r' % template) @classmethod def _template_basic(cls, parent): return cls.create_subpage( parent, title=_('Basic'), short_description=_('Elementary programming problems.'), slug='basic', material_icon='insert_emoticon', ) @classmethod def _template_conditionals(cls, parent): return cls.create_subpage( parent, title=_('Conditionals'), short_description=_('Conditional flow control (if/else).'), slug='conditionals', material_icon='code', ) @classmethod def _template_loops(cls, parent): return cls.create_subpage( parent, title=_('Loops'), short_description=_('Iterations with for/while commands.'), slug='loops', material_icon='loop', ) @classmethod def _template_functions(cls, parent): return cls.create_subpage( parent, title=_('Functions'), short_description=_('Organize code using functions.'), slug='functions', material_icon='functions', ) @classmethod def _template_files(cls, parent): return cls.create_subpage( parent, title=_('Files'), short_description=_('Open, process and write files.'), slug='files', material_icon='insert_drive_file', ) @classmethod def _template_lists(cls, parent): return cls.create_subpage( parent, title=_('Lists'), short_description=_('Linear data structures.'), slug='lists', material_icon='list', ) def save(self, *args, **kwargs): if self.title is None: self.title = _('List of activities') if self.slug is None: self.slug = 'activities' super().save(*args, **kwargs) def score_board_total(self): """ Return a score board mapping with the total score for each user. """ board = self.score_board() scores = ScoreMap(self.title) for k, L in board.items(): scores[k] = sum(L) return scores # Serving pages template = 'lms/activities/section.jinja2' def get_context(self, request, *args, **kwargs): return dict( super().get_context(request, *args, **kwargs), object_list=[obj.specific for obj in self.get_children()] ) # Wagtail Admin parent_page_types = [ActivityList] content_panels = mixins.ShortDescriptionPage.content_panels + [ panels.FieldPanel('material_icon') ]
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'), ]
from django.utils.translation import ugettext_lazy as _ from codeschool.fixes.wagtailadmin import WagtailAdmin from codeschool.models import Page from codeschool import panels from . import models class AttendancePageAdmin(WagtailAdmin): class Meta: model = models.AttendancePage abstract = True subpage_types = [] content_panels = \ Page.content_panels + [ panels.InlinePanel('attendance_sheet_single_list', max_num=1, min_num=1), ] models.AttendanceSheet.panels = [ panels.FieldPanel('max_attempts'), panels.FieldPanel('expiration_minutes'), panels.FieldPanel('max_string_distance'), ]
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 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 AnswerKey(models.Model): """ Represents an answer to some question given in some specific computer language plus the placeholder text that should be displayed. """ class ValidationError(Exception): pass class Meta: verbose_name = _('answer key') verbose_name_plural = _('answer keys') unique_together = [('question', 'language')] question = models.ParentalKey(CodingIoQuestion, related_name='answers') language = models.ForeignKey( ProgrammingLanguage, related_name='+', ) source = models.TextField( _('answer source code'), blank=True, help_text=_( 'Source code for the correct answer in the given programming ' 'language.'), ) placeholder = models.TextField( _('placeholder source code'), blank=True, help_text=_( 'This optional field controls which code should be placed in ' 'the source code editor when a question is opened. This is ' 'useful to put boilerplate or even a full program that the ' 'student should modify. It is possible to configure a global ' 'per-language boilerplate and leave this field blank.'), ) source_hash = models.CharField( max_length=32, blank=True, help_text=_('Hash computed from the reference source'), ) iospec_hash = models.CharField( max_length=32, blank=True, help_text=_('Hash computed from reference source and iospec_size.'), ) iospec_source = models.TextField( _('expanded source'), blank=True, help_text=_( 'Iospec source for the expanded testcase. This data is computed ' 'from the reference iospec source and the given reference program ' 'to expand the outputs from the given inputs.')) objects = AnswerKeyQueryset.as_manager() iospec_size = property(lambda x: x.question.iospec_size) @lazy def iospec(self): return parse_iospec(self.iospec_source) def __repr__(self): return '<AnswerKeyItem: %s (%s)>' % (self.question, self.language) def __str__(self): return '%s (%s)>' % (self.question, self.language) def clean(self): super().clean() if self.question is None: return # We only have to update if the parent's hash is incompatible with the # current hash and the source field is defined. We make this test to # perform the expensive code re-evaluation only when strictly necessary parent_hash = self.parent_hash() source_hash = md5hash(self.source) if parent_hash != self.iospec_hash or source_hash != self.source_hash: try: iospec = self.question.iospec except Exception: raise ValidationError( _('cannot register answer key for question with invalid ' 'iospec.')) result = self._update_state(iospec, self.source, self.language) self.iospec_source = result.source() self.source_hash = source_hash self.iospec_hash = parent_hash def update(self, commit=True): """ Update the internal iospec source and hash keys to match the given parent iospec value. It raises a ValidationError if the source code is invalid. """ iospec = self.question.iospec result = self._update_state(iospec, self.source, self.language) self.iospec_source = result.source() self.source_hash = md5hash(self.source) self.iospec_hash = self.parent_hash() if commit: self.save() def _update_state(self, iospec, source, language): """ Worker function for the .update() and .clean() methods. Update the hashes and the expanded iospec_source for the answer key. """ # We expand inputs and compute the result for the given source code # string language = language.ejudge_ref() if len(iospec) <= self.iospec_size: iospec.expand_inputs(self.iospec_size) result = run_code(source, iospec, language) # Check if the result has runtime or build errors if result.has_errors: for testcase in iospec: result = run_code(source, testcase, language) if result.has_errors: error_dic = { 'error': escape(result.get_error_message()), 'iospec': escape(testcase.source()) } raise ValidationError( {'source': mark_safe(ERROR_TEMPLATE % error_dic)}) # The source may run fine, but still give results that are inconsistent # with the given testcases. This will only be noticed if the user # provides at least one simple IO test case. for (expected, value) in zip(iospec, result): expected_source = expected.source().rstrip() value_source = value.source().rstrip() if expected.is_simple and expected_source != value_source: msg = _( '<div class="error-message">' 'Your program produced invalid results in this tescase:\n' '<br>\n' '<pre>%(diff)s</pre>\n' '</div>') error = { 'diff': '\n'.join( differ.compare(expected.source().rstrip().splitlines(), value.source().rstrip().splitlines())) } msg = mark_safe(msg % error) raise ValidationError({'source': msg}) # Now we save the result because it has all the computed expansions return result def save(self, *args, **kwds): if 'iospec' in self.__dict__: self.iospec_source = self.iospec.source() super().save(*args, **kwds) def run(self, source=None, iospec=None): """ Runs the given source against the given iospec. If no source is given, use the reference implementation. If no iospec is given, user the default. The user may also pass a list of input strings. """ source = source or self.source iospec = iospec or self.iospec if not source: raise ValueError('a source code string must be provided.') return run_code(source, iospec, self.language.ejudge_ref()) def parent_hash(self): """ Return the iospec hash from the question current iospec/iospec_size. """ parent = self.question return md5hash(parent.iospec_source + str(parent.iospec_size)) # Wagtail admin panels = [ panels.FieldPanel('language'), panels.FieldPanel('source'), panels.FieldPanel('placeholder'), ]
class Course(models.RoutablePageMixin, models.TimeStampedModel, models.Page): """ One specific occurrence of a course for a given teacher in a given period. """ discipline = models.ForeignKey('Discipline', blank=True, null=True, on_delete=models.DO_NOTHING) teacher = models.ForeignKey(models.User, related_name='courses_as_teacher', on_delete=models.DO_NOTHING) students = models.ManyToManyField( models.User, related_name='courses_as_student', blank=True, ) staff = models.ManyToManyField( models.User, related_name='courses_as_staff', blank=True, ) weekly_lessons = models.BooleanField( _('weekly lessons'), default=False, help_text=_( 'If true, the lesson spans a whole week. Othewise, each lesson ' 'would correspond to a single day/time slot.'), ) accept_subscriptions = models.BooleanField( _('accept subscriptions'), default=True, help_text=_('Set it to false to prevent new student subscriptions.'), ) is_public = models.BooleanField( _('is it public?'), default=True, help_text=_( 'If true, all students will be able to see the contents of the ' 'course. Most activities will not be available to non-subscribed ' 'students.'), ) subscription_passphrase = models.CharField( _('subscription passphrase'), default=random_subscription_passphase, max_length=140, help_text=_( 'A passphrase/word that students must enter to subscribe in the ' 'course. Leave empty if no passphrase should be necessary.'), blank=True, ) short_description = models.CharField(max_length=140, blank=True) description = models.RichTextField(blank=True) activities_template = models.CharField( max_length=20, choices=[ ('programming-beginner', _('A beginner programming course')), ('programming-intermediate', _('An intermediate programming course')), ('programming-marathon', _('A marathon-level programming course')), ], blank=True) @lazy def academic_description(self): return getattr(self.discipline, 'description', '') @lazy def syllabus(self): return getattr(self.discipline, 'syllabus', '') objects = CourseManager() @property def calendar_page(self): content_type = models.ContentType.objects.get(app_label='cs_core', model='calendarpage') return apps.get_model('cs_core', 'CalendarPage').objects.get( depth=self.depth + 1, path__startswith=self.path, content_type_id=content_type, ) @property def activities_page(self): content_type = models.ContentType.objects.get(app_label='cs_questions', model='questionlist') return apps.get_model('cs_questions', 'QuestionList').objects.get( depth=self.depth + 1, path__startswith=self.path, content_type_id=content_type, ) def save(self, *args, **kwargs): with transaction.atomic(): created = self.id is None if not self.path: created = False root = model_reference.load('course-list') root.add_child(instance=self) else: super().save(*args, **kwargs) if created: self.create_calendar_page() self.create_activities_page() def create_calendar_page(self): """ Creates a new calendar page if it does not exist. """ model = apps.get_model('courses', 'calendar') calendar = model() self.add_child(instance=calendar) def create_activities_page(self): """ Creates a new activities page if it does not exist. """ model = apps.get_model('activities', 'activitylist') activities = model( title=_('Activities'), slug='activities', short_description=ACTIVITY_DESCRIPTION % {'name': self.title}, ) self.add_child(instance=activities) def enroll_student(self, student): """ Register a new student in the course. """ self.students.add(student) self.update_friendship_status(student) def is_registered(self, user): """ Check if user is associated with the course in any way. """ if self.teacher == user: return True elif user in self.students.all(): return True elif user in self.staff.all(): return True else: return False def update_friendship_status(self, student=None): """ Recompute the friendship status for a single student by marking it as a colleague of all participants in the course. If no student is given, update the status of all enrolled students. """ update = self._update_friendship_status if student is None: for student in self.students.all(): update(student) else: update(student) def _update_friendship_status(self, student): for colleague in self.students.all(): if colleague != student: FriendshipStatus.objects.get_or_create( owner=student, other=colleague, status=FriendshipStatus.STATUS_COLLEAGUE) def get_user_role(self, user): """Return a string describing the most privileged role the user has as in the course. The possible values are: teacher: Owns the course and can do any kind of administrative tasks in the course. staff: Teacher assistants. May have some privileges granted by the teacher. student: Enrolled students. visitor: Have no relation to the course. If course is marked as public, visitors can access the course contents. """ if user == self.teacher: return 'teacher' if user in self.staff.all(): return 'staff' if user in self.students.all(): return 'student' return 'visitor' def info_dict(self): """ Return an ordered dictionary with relevant internationalized information about the course. """ def yn(x): return _('Yes' if x else 'No') data = [ ('Teacher', hyperlink(self.teacher)), ('Created', self.created), ('Accepting new subscriptions?', yn(self.accept_subscriptions)), ('Private?', yn(not self.is_public)), ] if self.academic_description: data.append(('Description', self.academic_description)) if self.syllabus: data.append(('Description', self.academic_description)) return OrderedDict([(_(k), v) for k, v in data]) # Serving pages template = 'courses/detail.jinja2' def get_context(self, request, *args, **kwargs): return dict( super().get_context(request, *args, **kwargs), course=self, ) def serve(self, request, *args, **kwargs): if self.is_registered(request.user): return super().serve(request, *args, **kwargs) return self.serve_registration(request, *args, **kwargs) def serve_registration(self, request, *args, **kwargs): context = self.get_context(request) if request.method == 'POST': form = PassPhraseForm(request.POST) if form.is_valid(): self.enroll_student(request.user) return super().serve(request, *args, **kwargs) else: form = PassPhraseForm() context['form'] = form return render(request, 'courses/course-enroll.jinja2', context) # Wagtail admin parent_page_types = ['courses.CourseList'] subpage_types = [] content_panels = models.Page.content_panels + [ panels.MultiFieldPanel([ panels.FieldPanel('short_description'), panels.FieldPanel('description'), panels.FieldPanel('teacher') ], heading=_('Options')), panels.InlinePanel( 'time_slots', label=_('Time slots'), help_text=_('Define when the weekly classes take place.'), ), ] settings_panels = models.Page.settings_panels + [ panels.MultiFieldPanel([ panels.FieldPanel('weekly_lessons'), ], heading=_('Options')), panels.MultiFieldPanel([ panels.FieldPanel('accept_subscriptions'), panels.FieldPanel('is_public'), panels.FieldPanel('subscription_passphrase'), ], heading=_('Subscription')), ]
class CodeExhibit(Activity): """ Students can publish code + an associated image (think about turtle art or Processing) and then vote in the best submissions. """ class Meta: verbose_name = _('Code exhibit') verbose_name_plural = _('Code exhibits') description = models.RichTextField() language = models.ForeignKey( 'core.ProgrammingLanguage', on_delete=models.PROTECT, ) def get_submit_form(self, *args, **kwargs): class ExhibitEntryForm(forms.ModelForm): class Meta: model = ExhibitEntry fields = ['name', 'image', 'source'] return ExhibitEntryForm(*args, **kwargs) # Page rendering and views template = 'code_exhibit/code_exhibit.jinja2' def get_context(self, request, *args, **kwargs): context = super().get_context(request, *args, **kwargs) context['entries'] = self.entries.all() return context @bricks.route(r'^get-form/$') def route_submit_form(self, client): form = self.get_submit_form() context = {'form': form, 'language': self.language} html = render_to_string('code_exhibit/submit.jinja2', context, request=client.request) client.dialog(html=html) @models.route(r'^submit-entry/$') def route_submit(self, request): if request.method == 'POST': print(request.POST) form = self.get_submit_form(request.POST, request.FILES) if form.is_valid(): instance = form.save(commit=False) print('saving model', instance) instance.user = request.user instance.exhibit = self instance.save() else: print(form.errors) return redirect(self.get_absolute_url()) # Wagtail Admin content_panels = Activity.content_panels + [ panels.FieldPanel('description'), panels.FieldPanel('language'), panels.InlinePanel('entries'), ]
class Course(models.RoutablePageMixin, models.CodeschoolPage): """ One specific occurrence of a course for a given teacher in a given period. """ class Meta: parent_init_attribute = 'discipline' teachers = models.ManyToManyField( models.User, related_name='courses_as_teacher', blank=True, ) students = models.ManyToManyField( models.User, related_name='courses_as_student', blank=True, ) staff = models.ManyToManyField( models.User, related_name='courses_as_staff_p', blank=True, ) weekly_lessons = models.BooleanField( _('weekly lessons'), default=False, help_text=_( 'If true, the lesson spans a whole week. Othewise, each lesson ' 'would correspond to a single day/time slot.'), ) accept_subscriptions = models.BooleanField( _('accept subscriptions'), default=True, help_text=_('Set it to false to prevent new student subscriptions.'), ) is_public = models.BooleanField( _('is it public?'), default=False, help_text=_( 'If true, all students will be able to see the contents of the ' 'course. Most activities will not be available to non-subscribed ' 'students.'), ) subscription_passphrase = models.CharField( _('subscription passphrase'), max_length=140, help_text=_( 'A passphrase/word that students must enter to subscribe in the ' 'course. Leave empty if no passphrase should be necessary.'), blank=True, ) objects = PageManager.from_queryset(CourseQueryset)() short_description = delegate_to('discipline', True) long_description = delegate_to('discipline', True) short_description_html = delegate_to('discipline', True) long_description_html = delegate_to('discipline', True) lessons = property(lambda x: x.calendar_page.lessons) @property def calendar_page(self): content_type = models.ContentType.objects.get(app_label='cs_core', model='calendarpage') return apps.get_model('cs_core', 'CalendarPage').objects.get( depth=self.depth + 1, path__startswith=self.path, content_type_id=content_type, ) @property def questions_page(self): content_type = models.ContentType.objects.get(app_label='cs_questions', model='questionlist') return apps.get_model('cs_questions', 'QuestionList').objects.get( depth=self.depth + 1, path__startswith=self.path, content_type_id=content_type, ) @property def gradables_page(self): content_type = models.ContentType.objects.get(app_label='cs_core', model='gradablespage') return apps.get_model('cs_core', 'GradablesPage').objects.get( depth=self.depth + 1, path__startswith=self.path, content_type_id=content_type, ) @property def discipline(self): return self.get_parent().specific @discipline.setter def discipline(self, value): self.set_parent(value) @property def questions(self): return self.questions_page.questions def add_question(self, question, copy=True): """ Register a new question to the course. If `copy=True` (default), register a copy. """ self.questions.add_question(question, copy) def new_question(self, cls, *args, **kwargs): """ Create a new question instance by calling the cls with the given arguments and add it to the course. """ self.questions.new_question(cls, *args, **kwargs) def add_lesson(self, lesson, copy=True): """ Register a new lesson in the course. If `copy=True` (default), register a copy. """ self.lessons.add_lesson(lesson, copy) def new_lesson(self, *args, **kwargs): """ Create a new lesson instance by calling the Lesson constructor with the given arguments and add it to the course. """ self.lessons.new_lesson(*args, **kwargs) def register_student(self, student): """ Register a new student in the course. """ self.students.add(student) self.update_friendship_status(student) def update_friendship_status(self, student=None): """ Recompute the friendship status for a single student by marking it as a colleague of all participants in the course.. If no student is given, update the status of all enrolled students. """ update = self._update_friendship_status if student is None: for student in self.students.all(): update(student) else: update(student) def _update_friendship_status(self, student): # Worker function for update_friendship_status colleague_status = FriendshipStatus.STATUS_COLLEAGUE for colleague in self.students.all(): if colleague != student: try: FriendshipStatus.objects.get(owner=student, other=colleague) except FriendshipStatus.DoesNotExist: FriendshipStatus.objects.create(owner=student, other=colleague, status=colleague_status) def get_absolute_url(self): return url_reverse('course-detail', args=(self.pk, )) def get_user_role(self, user): """Return a string describing the most privileged role the user as in the course. The possible values are: teacher: Owns the course and can do any kind of administrative tasks in the course. staff: Teacher assistants. May have some privileges granted by the teacher. student: Enrolled students. visitor: Have no relation to the course. If course is marked as public, visitors can access the course contents. """ if user == self.teacher: return 'teacher' if user in self.staff.all(): return 'staff' if user in self.students.all(): return 'student' return 'visitor' def get_user_activities(self, user): """ Return a sequence of all activities that are still open for the user. """ activities = self.activities.filter(status=Activity.STATUS_OPEN) return activities.select_subclasses() def activity_duration(self): """ Return the default duration (in minutes) for an activity starting from now. """ return 120 def next_time_slot(self): """Return the start and end times for the next class in the course. If a time slot is currently open, return it.""" now = timezone.now() return now, now + timezone.timedelta(self.activity_duration()) def next_date(self, date=None): """Return the date of the next available time slot.""" def can_view(self, user): return user != annonymous_user() def can_edit(self, user): return user in self.teachers.all() or user == self.owner def get_context(self, request, *args, **kwargs): context = super().get_context(request, *args, **kwargs) context['activities'] = self.questions return context # Wagtail admin parent_page_types = ['cs_core.Discipline'] subpage_types = [] content_panels = Page.content_panels + [ panels.InlinePanel( 'time_slots', label=_('Time slots'), help_text=_('Define when the weekly classes take place.'), ), ] settings_panels = Page.settings_panels + [ panels.MultiFieldPanel([ panels.FieldPanel('weekly_lessons'), panels.FieldPanel('is_public'), ], heading=_('Options')), panels.MultiFieldPanel([ panels.FieldPanel('accept_subscriptions'), panels.FieldPanel('subscription_passphrase'), ], heading=_('Subscription')), ]
class CodingIoQuestion(Question): """ CodeIo questions evaluate source code and judge them by checking if the inputs and corresponding outputs match an expected pattern. """ class Meta: verbose_name = _('input/output question') verbose_name_plural = _('input/output questions') iospec_size = models.PositiveIntegerField( _('number of iospec template expansions'), default=10, help_text=_( 'The desired number of test cases that will be computed after ' 'comparing the iospec template with the answer key. This is only a ' 'suggested value and will only be applied if the response template ' 'uses input commands to generate random input.'), ) iospec_source = models.TextField( _('response template'), help_text=_( 'Template used to grade I/O responses. See ' 'http://pythonhosted.org/iospec for a complete reference on the ' 'template format.'), ) iospec_hash = models.CharField( max_length=32, blank=True, help_text=_('A hash to keep track of iospec updates.'), ) timeout = models.FloatField( _('timeout in seconds'), blank=True, default=1.0, help_text=_( 'Defines the maximum runtime the grader will spend evaluating ' 'each test case.'), ) is_usable = models.BooleanField( _('is usable'), help_text=_( 'Tells if the question has at least one usable iospec entry. A ' 'complete iospec may be given from a single iospec source or by a ' 'combination of a valid source and a reference computer program.')) is_consistent = models.BooleanField( _('is consistent'), help_text=_( 'Checks if all given answer keys are consistent with each other. ' 'The question might become inconsistent by the addition of an ' 'reference program that yields different results from the ' 'equivalent program in a different language.')) @lazy def iospec(self): """ The IoSpec structure corresponding to the iospec_source. """ return parse_iospec(self.iospec_source) @property def is_answer_key_complete(self): """ Return True if an answer key item exists for all programming languages. """ refs = self.is_answer_keys.values('language__ref', flatten=True) all_refs = ProgrammingLanguage.objects.values('ref', flatten=True) return set(all_refs) == set(refs) @bound_property def language(self): """ Instances can be bound to a programming language. """ return getattr(self, '_language_bind', None) @language.setter def language(self, value): self._language_bind = programming_language(value, raises=False) @property def is_language_bound(self): return self.language is not None @property def default_language(self): """ The main language associated with this question if a single answer key is defined. """ return self.answer_key_items.get().language def _language(self, language=None, raises=True): # Shortcut used internally to normalize the given language if language is None: return self.language or self.default_language return programming_language(language, raises) def __init__(self, *args, **kwargs): # Supports automatic conversion between iospec data and iospec_source iospec = kwargs.pop('iospec', None) if iospec: kwargs['iospec_source'] = iospec.source() self.iospec = iospec super().__init__(*args, **kwargs) def clean(self): """ Validate the iospec_source field. """ super().clean() # We first should check if the iospec_source has been changed and thus # requires a possibly expensive validation. source = self.iospec_source iospec_hash = md5hash(source) if self.iospec_hash != iospec_hash: try: self.iospec = iospec.parse_string(self.iospec_source) except Exception: raise ValidationError( {'iospec_source': _('invalid iospec syntax')}) else: self.iospec_hash = iospec_hash if self.pk is None: self.is_usable = self.iospec.is_simple self.is_consistent = True else: self.is_usable = self._is_usable(self.iospec) self.is_consistent = self._is_consistent(self.iospec) def _is_usable(self, iospec): """ This function is triggered during the clean() validation when a new iospec data is inserted into the database. """ # Simple iospecs are always valid since they can be compared with # arbitrary programs. if iospec.is_simple_io: return True # For now we reject all complex iospec structures return False def _is_consistent(self, iospec): """ This function is triggered during the clean() validation when a new iospec data is inserted into the database. """ # Simple iospecs always produce consistent answer keys since we prevent # invalid reference programs of being inserted into the database # during AnswerKeyItem validation. if iospec.is_simple_io: return True # For now we reject all complex iospec structures return False # Serialization methods: support markio and sets it as the default # serialization method for CodingIoQuestion's @classmethod def load_markio(cls, source): """ Creates a CodingIoQuestion object from a Markio object or source string and saves the resulting question in the database. This function can run without touching the database if the markio file does not define any information that should be saved in an answer key. Args: source: A string with the Markio source code. Returns: question: A question object. """ import markio if isinstance(source, markio.Markio): data = source else: data = markio.parse_string(source) # Create question object from parsed markio data question = CodingIoQuestion.objects.create( title=data.title, author_name=data.author, timeout=data.timeout, short_description=data.short_description, long_description=data.description, iospec_source=data.tests, ) # Add answer keys answer_keys = {} for (lang, answer_key) in data.answer_key.items(): language = programming_language(lang) key = question.answer_keys.create(language=language, source=answer_key) answer_keys[lang] = key for (lang, placeholder) in data.placeholder.items(): if placeholder is None: continue try: answer_keys[lang].placeholder = placeholder answer_keys[lang].save(update_fields=['placeholder']) except KeyError: language = ProgrammingLanguage.objects.get(lang) question.answer_keys.create(language=language, placeholder=placeholder) return question @classmethod def load(cls, format='markio', **kwargs): return super().load(format=format, **kwargs) def dump_markio(self): """ Serializes question into a string of Markio source. """ import markio tree = markio.Markio( title=self.name, author=self.author_name, timeout=self.timeout, short_description=self.short_description, description=self.long_description, tests=self.iospec_source, ) for key in self.answer_keys.all(): tree.add_answer_key(key.source, key.language.ref) tree.add_placeholder(key.placeholder, key.language.ref) return tree.source() def answer_key_item(self, language=None): """ Return the AnswerKeyItem instance for the requested language or None if no object is found. """ language = self._language(language) try: return self.answer_key_items.get(language=language) except AnswerKeyItem.DoesNotExist: return None def answer_key(self, language=None): """ Return the answer key IoSpec object associated with the given language. """ key = self.answer_key_item(language) if key is None or key.iospec_source is None: new_key = self.answer_key_item() if key == new_key: if self.iospec.is_simple: raise ValueError('no valid iospec is defined for the ' 'question') return iospec.expand_inputs(self.iospec_size) key = new_key # We check if the answer key item is synchronized with the parent hash if key.iospec_hash != key.parent_hash(): try: key.update(self.iospec) except ValidationError: return self.iospec return key.iospec def placeholder(self, language=None): """ Return the placeholder text for the given language. """ if key is None: return '' return key.placeholder def reference_source(self, language=None): """ Return the reference source code for the given language or None, if no reference is found. """ key = self.answer_key_item(language) if key is None: return '' return key.source def run_code(self, source=None, iospec=None, language=None): """ Run the given source code string for the programming language using the default IoSpec. If no code string is given, runs the reference source code, it it exists. """ if language is None: language = self.answer_key_items.get().language key = self.answer_key_item(language) return key.run(source, iospec) def update_iospec_source(self): """ Updates the iospec_source attribute with the current iospec object. Any modifications made to `self.iospec` must be saved explicitly to persist on the database. """ if 'iospec' in self.__dict__: self.iospec_source = self.iospec.source() def register_response_item(self, source, language=None, **kwargs): response_data = { 'language': self._language(language).ref, 'source': source, } kwargs.update(response_data=response_data) return super().register_response_item(**kwargs) # Serving pages and routing @srvice.route(r'^submit-response/$') def respond_route(self, client, source=None, language=None, **kwargs): """ Handles student responses via AJAX and a srvice program. """ if not language: client.dialog('<p>Please select the correct language</p>') return # Bug with <ace-editor>? if not source or source == '\x01\x01': client.dialog('<p>Internal error: please send it again!</p>') return language = programming_language(language) self.bind(client.request, language=language, **kwargs) response = self.register_response_item(source, autograde=True) html = render_html(response.feedback) client.dialog(html) @srvice.route(r'^placeholder/$') def get_placeholder_route(self, request, language): """ Return the placeholder code for some language. """ return self.get_placehoder(language) def get_context(self, request, *args, **kwargs): context = super().get_context(request, *args, **kwargs) context['form'] = ResponseForm(request.POST) return context # Wagtail admin content_panels = Question.content_panels[:] content_panels.insert( -1, panels.MultiFieldPanel([ panels.FieldPanel('iospec_size'), panels.FieldPanel('iospec_source'), ], heading=_('IoSpec definitions'))) content_panels.insert( -1, panels.InlinePanel('answer_key_items', label=_('Answer keys')))
class CodingIoQuestion(Question): """ CodeIo questions evaluate source code and judge them by checking if the inputs and corresponding outputs match an expected pattern. """ class Meta: verbose_name = _('Programming question (IO-based)') verbose_name_plural = _('Programming questions (IO-based)') EXT_TO_METHOD_CONVERSIONS = dict( Question.EXT_TO_METHOD_CONVERSIONS, md='markio', ) iospec_size = models.PositiveIntegerField( _('number of iospec template expansions'), default=10, help_text=_( 'The desired number of test cases that will be computed after ' 'comparing the iospec template with the answer key. This is only a ' 'suggested value and will only be applied if the response template ' 'uses input commands to generate random input.'), ) iospec_source = models.TextField( _('response template'), help_text=_( 'Template used to grade I/O responses. See ' 'http://pythonhosted.org/iospec for a complete reference on the ' 'template format.'), ) iospec_hash = models.CharField( max_length=32, blank=True, help_text=_('A hash to keep track of iospec updates.'), ) timeout = models.FloatField( _('timeout in seconds'), blank=True, default=1.0, help_text=_( 'Defines the maximum runtime the grader will spend evaluating ' 'each test case.'), ) language = models.ForeignKey( ProgrammingLanguage, on_delete=models.SET_NULL, blank=True, null=True, help_text=_( 'Programming language associated with question. Leave it blank in ' 'order to accept submissions in any programming language. This ' 'option should be set only for questions that tests specific ' 'programming languages constructs or require techniques that only ' 'make sense in specific programming languages.'), ) __iospec_updated = False __answers = () @lazy def iospec(self): """ The IoSpec structure corresponding to the iospec_source. """ return parse_iospec(self.iospec_source) def __init__(self, *args, **kwargs): # Supports automatic conversion between iospec data and iospec_source iospec = kwargs.pop('iospec', None) if iospec: kwargs['iospec_source'] = iospec.source() self.iospec = iospec super().__init__(*args, **kwargs) def load_from_file_data(self, file_data): fake_post = super().load_from_file_data(file_data) fake_post['iospec_source'] = self.iospec_source return fake_post def clean(self): """ Validate the iospec_source field. """ super().clean() # We first should check if the iospec_source has been changed and would # require a possibly expensive validation. source = self.iospec_source iospec_hash = md5hash(source) if self.iospec_hash != iospec_hash: try: self.iospec = iospec = parse_iospec(self.iospec_source) except Exception as ex: raise ValidationError( {'iospec_source': _('invalid iospec syntax: %s' % ex)}) # Now we check if the new iospec requires an answer key code and # if it has some answer key defined self.__iospec_updated = True return if (not iospec.is_expanded) and not self.answers.has_program(): raise ValidationError({ 'iospec_source': _('You iospec definition uses a command or an @input block ' 'and thus requires an example grading code. Please define ' 'an "Answer Key" item with source code for at least one ' 'programming language.') }) def load_from_markio(self, file_data): """ Load question parameters from Markio file. """ data = markio.parse(file_data) # Load simple data from markio self.title = data.title or self.title self.short_description = (data.short_description or self.short_description) self.timeout = data.timeout or self.timeout self.author_name = data.author or self.author_name self.iospec_source = data.tests or self.iospec_source # Load main description # noinspection PyUnresolvedReferences self.body = markdown_to_blocks(data.description) # Add answer keys answer_keys = OrderedDict() for (lang, answer_key) in data.answer_key.items(): language = programming_language(lang) key = self.answers.create(question=self, language=language, source=answer_key) answer_keys[lang] = key for (lang, placeholder) in data.placeholder.items(): if placeholder is None: continue try: answer_keys[lang].placeholder = placeholder except KeyError: language = ProgrammingLanguage.objects.get(lang) self.answer_keys.create(question=self, language=language, placeholder=placeholder) self.__answers = list(answer_keys.values()) # Serialization methods: support markio and sets it as the default # serialization method for CodingIoQuestion's @classmethod def load_markio(cls, source): """ Creates a CodingIoQuestion object from a Markio object or source string and saves the resulting question in the database. This function can run without touching the database if the markio file does not define any information that should be saved in an answer key. Args: source: A string with the Markio source code. Returns: question: A question object. """ raise NotImplementedError def dump_markio(self): """ Serializes question into a string of Markio source. """ tree = markio.Markio( title=self.name, author=self.author_name, timeout=self.timeout, short_description=self.short_description, description=self.long_description, tests=self.iospec_source, ) for key in self.answer_keys.all(): tree.add_answer_key(key.source, key.language.ref) tree.add_placeholder(key.placeholder, key.language.ref) return tree.source() def full_clean(self, *args, **kwargs): if self.__answers: self.answers = self.__answers super().full_clean(*args, **kwargs) def placeholder(self, language=None): """ Return the placeholder text for the given language. """ key = self.answers[language or self.language] if key is None: return '' return key.placeholder def reference_source(self, language=None): """ Return the reference source code for the given language or None, if no reference is found. """ key = self.answers[language or self.language] if key is None: return '' return key.source def run_code(self, source, language=None, iospec=None): """ Run the given source code string of the given programming language using the default or the given IoSpec. If no code string is given, runs the reference source code, if it exists. """ key = self.answers[language or self.language] return key.run(source, iospec) def update_iospec_source(self): """ Updates the iospec_source attribute with the current iospec object. Any modifications made to `self.iospec` must be saved explicitly to persist in the database. """ if 'iospec' in self.__dict__: self.iospec_source = self.iospec.source() def submit(self, user, source=None, language=None, **kwargs): # Fetch info from response_data response_data = kwargs.get('response_data', {}) if source is None and 'source' in response_data: source = response_data.pop('source') if language is None and 'language' in response_data: language = response_data.pop('language') # Assure language is valid language = language or self.language if not language: raise ValueError( 'could not determine the programming language for ' 'the submission') # Assure response data is empty if response_data: key = next(iter(response_data)) raise TypeError('invalid or duplicate parameter passed to ' 'response_data: %r' % key) # Construct response data and pass it to super response_data = { 'language': language.ref, 'source': source, } return super().submit(user, response_data=response_data, **kwargs) # Serving pages and routing template = 'questions/coding_io/detail.jinja2' template_submissions = 'questions/coding_io/submissions.jinja2' def get_context(self, request, *args, **kwargs): context = dict(super().get_context(request, *args, **kwargs), form=True) # Select default mode for the ace editor if self.language: context['default_mode'] = self.language.ace_mode() else: context['default_mode'] = get_config('CODESCHOOL_DEFAULT_ACE_MODE', 'python') # Enable language selection if self.language is None: context['select_language'] = True context['languages'] = ProgrammingLanguage.supported.all() else: context['select_language'] = False return context @srvice.route(r'^submit-response/$') def route_submit(self, client, source=None, language=None, **kwargs): """ Handles student responses via AJAX and a srvice program. """ # User must choose language if not language: if self.language is None: client.dialog('<p class="dialog-text">%s</p>' % _('Please select the correct language')) return language = self.language else: language = programming_language(language) # Bug with <ace-editor>? if not source or source == '\x01\x01': client.dialog('<p class="dialog-text">%s</p>' % _('Internal error: please send it again!')) return super().route_submit( client=client, language=language, source=source, ) @srvice.route(r'^placeholder/$') def route_placeholder(self, request, language): """ Return the placeholder code for some language. """ return self.get_placehoder(language) # Wagtail admin content_panels = Question.content_panels[:] content_panels.insert( -1, panels.MultiFieldPanel([ panels.FieldPanel('iospec_size'), panels.FieldPanel('iospec_source'), ], heading=_('IoSpec definitions'))) content_panels.insert( -1, panels.InlinePanel('answers', label=_('Answer keys'))) settings_panels = Question.settings_panels + [ panels.MultiFieldPanel([ panels.FieldPanel('language'), panels.FieldPanel('timeout'), ], heading=_('Options')) ]
class Profile(UserenaBaseProfile): """ Social information about users. """ class Meta: permissions = ( ('student', _('Can access/modify data visible to student\'s')), ('teacher', _('Can access/modify data visible only to Teacher\'s')), ) GENDER_MALE, GENDER_FEMALE = 0, 1 user = models.OneToOneField( User, verbose_name=_('user'), unique=True, blank=True, null=True, on_delete=models.SET_NULL, related_name='profile', ) school_id = models.CharField( _('school id'), max_length=50, blank=True, null=True, help_text=_('Identification number in your school issued id card.'), ) is_teacher = models.BooleanField(default=False) nickname = models.CharField(max_length=50, blank=True, null=True) phone = models.CharField(max_length=20, blank=True, null=True) gender = models.SmallIntegerField(_('gender'), choices=[(GENDER_MALE, _('Male')), (GENDER_FEMALE, _('Female'))], blank=True, null=True) date_of_birth = models.DateField(_('date of birth'), blank=True, null=True) website = models.URLField(blank=True, null=True) about_me = models.RichTextField(blank=True, null=True) # Delegates and properties username = delegate_to('user', True) first_name = delegate_to('user') last_name = delegate_to('user') email = delegate_to('user') @property def age(self): if self.date_of_birth is None: return None today = timezone.now().date() birthday = self.date_of_birth years = today.year - birthday.year birthday = datetime.date(today.year, birthday.month, birthday.day) if birthday > today: return years - 1 else: return years def __str__(self): if self.user is None: return __('Unbound profile') full_name = self.user.get_full_name() or self.user.username return __('%(name)s\'s profile') % {'name': full_name} def get_full_name_or_username(self): name = self.user.get_full_name() if name: return name else: return self.user.username def get_absolute_url(self): return reverse('auth:profile-detail', kwargs={'username': self.user.username}) # Serving pages template = 'cs_auth/profile-detail.jinja2' def get_context(self, request, *args, **kwargs): context = super().get_context(request, *args, **kwargs) context['profile'] = self return context # Wagtail admin panels = [ panels.MultiFieldPanel([ panels.FieldPanel('school_id'), ], heading='Required information'), panels.MultiFieldPanel([ panels.FieldPanel('nickname'), panels.FieldPanel('phone'), panels.FieldPanel('gender'), panels.FieldPanel('date_of_birth'), ], heading=_('Personal Info')), panels.MultiFieldPanel([ panels.FieldPanel('website'), ], heading=_('Web presence')), panels.RichTextFieldPanel('about_me'), ]
class Conditions(models.PolymorphicModel): """ Each activity can be bound to different sets of conditions that control aspects on how the activity should be graded and may place restrictions on how the students may respond to the given activity. """ class Meta: verbose_name = _('conditions') verbose_name_plural = _('conditions') name = models.CharField(_('name'), max_length=140, blank=True, help_text=_('A string identifier.')) single_submission = models.BooleanField( _('single submission'), default=False, help_text=_( 'If set, students will be allowed to send only a single ' 'submission per activity.', ), ) delay_feedback = models.BooleanField( _('delay feedback'), default=False, help_text=_( 'If set, students will be only be able to see the feedback after ' 'the activity expires its deadline.')) programming_language = models.ForeignKey( 'core.ProgrammingLanguage', blank=True, null=True, related_name='+', help_text=_( 'Defines the required programming language for code-based student ' 'responses, when applicable. Leave it blank if you do not want to ' 'enforce any programming language.')) text_format = models.ForeignKey( 'core.FileFormat', blank=True, null=True, related_name='+', help_text=_( 'Defines the required file format for text-based responses, when ' 'applicable. Leave it blank if you do not want to enforce any ' 'text format.')) def __str__(self): return self.name or 'Untitled condition object.' panels = [ panels.FieldPanel('name'), panels.MultiFieldPanel([ panels.FieldPanel('single_submission'), panels.FieldPanel('delay_feedback'), ], heading=_('Submissions')), panels.MultiFieldPanel([ panels.FieldPanel('deadline'), panels.FieldPanel('hard_deadline'), panels.FieldPanel('delay_penalty'), ], heading=_('Deadline')), panels.MultiFieldPanel([ panels.FieldPanel('get_programming_language'), panels.FieldPanel('text_format'), ], heading=_('Deadline')), ]
class Profile(UserenaBaseProfile, models.Page): """ Social information about users. """ class Meta: permissions = ( ('student', _('Can access/modify data visible to student\'s')), ('teacher', _('Can access/modify data visible only to Teacher\'s')), ) user = models.OneToOneField( User, unique=True, blank=True, null=True, on_delete=models.SET_NULL, verbose_name=_('user'), related_name='profile', ) school_id = models.CharField( _('school id'), help_text=_('Identification number in your school issued id card.'), max_length=50, blank=True, null=True) nickname = models.CharField(max_length=50, blank=True, null=True) phone = models.CharField(max_length=20, blank=True, null=True) gender = models.SmallIntegerField(_('gender'), choices=[(0, _('male')), (1, _('female'))], blank=True, null=True) date_of_birth = models.DateField(_('date of birth'), blank=True, null=True) website = models.URLField(blank=True, null=True) about_me = models.RichTextField(blank=True, null=True) objects = ProfileManager() # Delegates and properties username = delegate_to('user', True) first_name = delegate_to('user') last_name = delegate_to('user') email = delegate_to('user') @property def short_description(self): return '%s (id: %s)' % (self.get_full_name_or_username(), self.school_id) @property def age(self): if self.date_of_birth is None: return None today = timezone.now().date() birthday = self.date_of_birth years = today.year - birthday.year birthday = datetime.date(today.year, birthday.month, birthday.day) if birthday > today: return years - 1 else: return years def __str__(self): if self.user is None: return __('Unbound profile') full_name = self.user.get_full_name() or self.user.username return __('%(name)s\'s profile') % {'name': full_name} def save(self, *args, **kwargs): user = self.user if not self.title: self.title = self.title or __("%(name)s's profile") % { 'name': user.get_full_name() or user.username } if not self.slug: self.slug = user.username.replace('.', '-') # Set parent page, if necessary if not self.path: root = ProfileList.objects.instance() root.add_child(instance=self) else: super().save(*args, **kwargs) def get_full_name_or_username(self): name = self.user.get_full_name() if name: return name else: return self.user.username # Serving pages template = 'cs_auth/profile-detail.jinja2' def get_context(self, request, *args, **kwargs): context = super().get_context(request, *args, **kwargs) context['profile'] = self return context # Wagtail admin parent_page_types = ['ProfileList'] content_panels = models.Page.content_panels + [ panels.MultiFieldPanel([ panels.FieldPanel('school_id'), ], heading='Required information'), panels.MultiFieldPanel([ panels.FieldPanel('nickname'), panels.FieldPanel('phone'), panels.FieldPanel('gender'), panels.FieldPanel('date_of_birth'), ], heading=_('Personal Info')), panels.MultiFieldPanel([ panels.FieldPanel('website'), ], heading=_('Web presence')), panels.RichTextFieldPanel('about_me'), ]
class AnswerKey(models.Model): """ Represents an answer to some question given in some specific computer language plus the placeholder text that should be displayed. """ NULL_SOURCE_HASH = md5hash('') class ValidationError(Exception): pass class Meta: verbose_name = _('answer key') verbose_name_plural = _('answer keys') unique_together = [('question', 'language')] question = models.ParentalKey( CodingIoQuestion, related_name='answers' ) language = models.ForeignKey( ProgrammingLanguage, related_name='+', ) source = models.TextField( _('answer source code'), blank=True, help_text=_( 'Source code for the correct answer in the given programming ' 'language.' ), ) placeholder = models.TextField( _('placeholder source code'), blank=True, help_text=_( 'This optional field controls which code should be placed in ' 'the source code editor when a question is opened. This is ' 'useful to put boilerplate or even a full program that the ' 'student should modify. It is possible to configure a global ' 'per-language boilerplate and leave this field blank.' ), ) source_hash = models.CharField( max_length=32, default=NULL_SOURCE_HASH, help_text=_('Hash computed from the reference source'), ) error_message = models.TextField( _('error message'), blank=True, help_text=_( 'If an error is found on post-validation, an error message is ' 'stored in here.' ) ) def __repr__(self): return '<AnswerKey: %s>' % self def __str__(self): try: title = self.question.title except: title = '<untitled>' return '%s (%s)' % (title, self.language) def save(self, *args, **kwargs): self.source_hash = md5hash(self.source) super().save(*args, **kwargs) def clean(self): try: check_syntax(self.source, lang=self.language.ejudge_ref()) except SyntaxError as ex: msg = _('Invalid syntax: %(msg)') % {'msg': str(ex)} raise ValidationError({'source': msg}) super().clean() # Validation is async: # # We first run basic validations in the foreground and later attempt # at more detailed validations that requires us to run source code (and # thus possibly wait a long time). # # If this later validation step encounters errors, it saves them on # the model instance. The next time the model runs, we can re-raise # them on the interface. The user has an option to bypass these checks. # Changing the code or the iospec entries should expire these # errors. if self.error_message and not self.is_ignoring_validation_errors(): raise ValidationError({'source': mark_safe(self.error_message)}) def is_ignoring_validation_errors(self): """ True to ignore errors found in post-validation. """ return self.question.ignore_validation_errors def set_error_message(self, message): """ Saves error message. """ try: self.error_message = message.__html__() except AttributeError: self.error_message = escape(message) def has_changed_source(self): """ Return True if source is not consistent with its hash. """ return self.source_hash != md5hash(self.source) # # # def single_reference(self): """ Return True if it is the only answer key in the set that defines a source attribute. """ if not self.source: return False try: return self.question.answers.has_program().get() == self except self.DoesNotExist: return False # Wagtail admin panels = [ panels.FieldPanel('language'), panels.FieldPanel('source'), panels.FieldPanel('placeholder'), ]
class Activity(AbsoluteUrlMixin, HasScorePage, metaclass=ActivityMeta): """ 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') 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.'), ) objects = ActivityManager() #: Define the default material icon used in conjunction with instances of #: the activity class. personalization_default_icon = 'material:help' #: The response class associated with the given activity. submission_class = None #: Model template template = 'lms/activities/activity.jinja2' #: Dictionary with extra static content that should be appended to the #: context for instances of the model. extra_context = {} def clean(self): super().clean() if not self.author_name and self.owner: self.author_name = self.owner.get_full_name() @property def submissions(self): return self.submission_class.objects.filter( response__activity_page_id=self.id) # 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 = self._normalize_user(user) # context = self._normalize_response_context(context) # get_response = apps.get_model('cs_core', 'Response').get_response # return get_response(user=user, context=context, activity=self) def submit(self, user, response_data=None, autograde=False, recycle=False, submission_kwargs=None): """ Create a new Submission object for the given question and saves it on the database. Args: user: The user who submitted the submission. response_data: A dictionary that is used to initialize the response_data attribute of the resulting submission object. autograde: If true, calls the autograde() method in the submission to give the automatic gradings. recycle: If true, recycle submission objects with the same content as the current submission. If a submission exists with the same content as the current submission, it simply returns that submission. The resulting object have a boolean ``.recycled`` attribute that tells if it was recycled or not. submission_kwargs: A dictionary with extra kwargs to be passed to the class' submission_class constructor. """ # Fetch submission class try: submission_class = self.submission_class if submission_class is None: raise AttributeError except AttributeError: raise ImproperlyConfigured( '%s must define a submission_class attribute with the ' 'appropriate response class.' % self.__class__.__name__) # Normalize inputs submission_kwargs = submission_kwargs or {} response_data = response_data or {} # Add response information to the given submission kwargs response = self.responses.response_for_user(user) # We compute the hash and compare it with values on the database # if recycle is enabled response_hash = submission_class.response_data_hash(response_data) submission = None recycled = False if recycle: recyclable = submission_class.objects.filter( response=response, response_hash=response_hash, ).order_by('created') for pk, value in recyclable.values_list('id', 'response_data'): if value == response_data: submission = recyclable.get(pk=pk) recycled = True break # Proceed if no submission was created if submission is None: submission = submission_class( user=user, response=response, response_hash=response_hash, response_data=response_data, **submission_kwargs, ) # Finalize submission item submission.autograde() submission.recycled = recycled return submission # 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 import_responses_from_context(self, from_context, to_context, # user=None, # discard=False): # """ # Import all responses associated with `from_context` to the `to_context`. # # If discard=True, responses in the original context are discarded. # """ # # if from_context == to_context: # raise ValueError('contexts cannot be the same') # # responses = self.response_items(user=user, context=from_context) # for response_item in responses: # old_response = response_item.response # new_response = self.get_response(context=to_context, # user=old_response.user) # if not discard: # response_item.pk = None # response_item.response = new_response # response_item.save() # # # Serving pages # def response_context_from_request(self, request): # """ # Return the context from the request object. # """ # # try: # context_pk = request.GET['context'] # objects = apps.get_model('cs_core', 'ResponseContext').objects # return objects.get(pk=context_pk) # except KeyError: # return self.default_context def get_context(self, request, *args, **kwargs): return dict( super().get_context(request, *args, **kwargs), activity=self, **self.extra_context, ) # 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 # # Plagiarism detection # def best_responses(self, context): """ Return a dictionary mapping users to their best responses. """ mapping = {} responses = self.responses.filter(context=context) for response in responses: mapping[response.user] = response.best_submission() return mapping def find_identical_responses(self, context, key=None, cmp=None, thresh=1): """ Finds all responses with identical response_data in the set of best responses. Args: key: The result of key(response_data) is used for normalizing the different responses in the response set. cmp: A comparison function that take the outputs of key(x) for a pair of responses and return True if the two arguments are to be considered equal. thresh: Minimum threshold for the result of cmp(x, y) to be considered plagiarism. """ key = key or (lambda x: x) responses = self.best_responses(context).values() response_data = [(x, key(x.response_data)) for x in responses if x is not None] # We iterate this list in O^2 complexity by comparing every pair of # responses and checking if cmp(data1, data2) returns a value greater # than or equal thresh. bad_pairs = {} cmp = cmp or (lambda x, y: x == y) for i, (resp_a, key_a) in enumerate(response_data): for j in range(i + 1, len(response_data)): resp_b, key_b = response_data[j] value = cmp(key_a, key_b) if value >= thresh: bad_pairs[resp_a, resp_b] = value return bad_pairs def group_identical_responses(self, context, key=None, keep_single=True): key = key or (lambda x: json.dumps(x)) bad_values = {} for response in self.best_responses(context).values(): if response is None: continue key_data = key(response.response_data) response_list = bad_values.setdefault(key_data, []) response_list.append(response) return bad_values # # 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() # Permissions 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 = [] # content_panels = models.Page.content_panels + [ # panels.MultiFieldPanel([ # # panels.RichTextFieldPanel('short_description'), # ], heading=_('Options')), # ] # promote_panels = models.Page.promote_panels + [ # panels.FieldPanel('icon_src') # ] settings_panels = models.Page.settings_panels + [ panels.MultiFieldPanel([ panels.FieldPanel('points_total'), panels.FieldPanel('stars_total'), ], heading=_('Scores')) ]