class ExpectedUsername(models.Model): """A string of an allowed value for e.g., white listing user names that can enroll in a specific course/activity/event etc. This class is used to create white lists of users that might not exist yet in the database. If you are sure that your users exist, maybe it is more convenient to create a regular Group.""" username = models.CharField(max_length=100, ) listener_id = models.IntegerField( null=True, blank=True, ) listener_type = models.ForeignKey( models.ContentType, null=True, blank=True, ) listener_action = models.CharField( max_length=30, blank=True, ) @property def exists(self): return models.User.objects.filter(username=self.username).size() == 1 @property def is_active(self): try: return models.User.objects.get(username=self.username).is_active except models.User.DoesNotExist: return False @property def listener(self): ctype = models.ContentType.objects.get(pk=self.listener_type) cls = ctype.model_class() try: return cls.objects.get(pk=self.listener_id) except cls.DoesNotExist: return None @property def user(self): return models.User.objects.get(username=self.username) def notify(self, user=None): """ Notify that user with the given username was created. """ if self.action: listener = self.listener if listener is not None: callback = getattr(listener, action) callback(user or self.user) def __str__(self): return self.username
class FreeFormQuestion(Question): """ A free form question is *not* automatically graded. The student can submit a resource that can be a text, code, file, image, etc and a human has to analyse and grade it manually. """ Type = Type type = models.IntegerField( _('Text type'), choices=[ (Type.CODE.value, _('Code')), (Type.RICHTEXT.value, _('Rich text')), (Type.FILE.value, _('File')), (Type.PHYSICAL.value, _('Physical delivery')), ], default=Type.CODE, ) filter = models.CharField( _('filter'), max_length=30, blank=True, help_text=_( 'Filters the response by some criteria.' ), ) class Meta: autograde = False
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( 'Classroom', 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 GivenPoints(models.TimeStampedModel): """ Handles users experience points for any given event. Points are associated to a unique (user, token, index) tuple. The token + index pair is used to identify resources in codeschool that may emmit points. This resources can be model instances or any arbitrary combination of string and ints. """ user = models.ForeignKey(models.User) points = models.IntegerField(default=0) token = models.CharField(max_length=100) index = models.IntegerField(blank=True, null=True) objects = GivenPointsQuerySet.as_manager() class Meta: unique_together = [('user', 'token', 'index')]
class GivenXp(models.Model): """ Handles users experience points. """ class Meta: unique_together = [('user', 'token', 'index')] user = models.ForeignKey(models.User) points = models.IntegerField(default=0) token = models.CharField(max_length=100) index = models.IntegerField(blank=True, null=True) objects = GivenXpManager() _leaderboard_expire_time = time() - 1 # begin at expired state @classmethod def total_score(cls, user): """ The total Xp points associated to the given user. """ points = cls.objects.filter(user=user).values_list('points', flat=True) return sum(points) @classmethod def leaderboard(cls, force_refresh=False): """ Construct the leaderboard from all GivenXp entries. The leaderboard is cached and refreshed at most every 5min. """ if force_refresh or time() <= cls._leaderboard_expire_time: cls._leaderboard_cache = counter = Counter() values = cls.objects\ .select_related('user')\ .values_list('user', 'points') for user, points in values: counter[user] += points cls._leaderboard_expire_time = time() + 5 * 60 return cls._leaderboard_cache
class ExhibitEntry(models.ClusterableModel): """ Each user submission """ class Meta: unique_together = [('user', 'exhibit')] exhibit = models.ParentalKey(CodeExhibit, related_name='entries') user = models.ForeignKey(models.User, related_name='+', on_delete=models.CASCADE) name = models.CharField(max_length=200) source = models.TextField() image = models.ImageField(upload_to='images/code_exhibit/') # image = models.FileField(upload_to='images/code_exhibit/') votes_from = models.ManyToManyField(models.User, related_name='+') num_votes = models.IntegerField(default=int) objects = ExhibitEntryQuerySet.as_manager() def vote(self, user): """ Register a vote from user. """ if not self.votes_from.filter(id=user.id).count(): self.votes_from.add(user) self.num_votes += 1 self.save(update_fields=['num_votes']) def unvote(self, user): """ Remove a vote from user. """ if self.votes_from.filter(id=user.id).count(): self.votes_from.remove(user) self.num_votes -= 1 self.save(update_fields=['num_votes']) def icon_for_user(self, user): if user in self.votes_from.all(): return 'star' return 'start_border' # Wagtail admin panels = [ panels.FieldPanel('user'), panels.FieldPanel('source'), panels.FieldPanel('image'), panels.FieldPanel('num_votes'), ]
class TimeSlot(models.Model): """Represents the weekly time slot that can be assigned to classes for a given course.""" class Meta: unique_together = ('course', 'weekday') weekday = models.IntegerField( choices=[(0, 'Monday'), (1, 'Tuesday'), (2, 'Wednesday'), (3, 'Thursday'), (4, 'Friday'), (5, 'Saturday'), (6, 'Sunday')] ) start = models.TimeField() end = models.TimeField() course = models.ForeignKey(Course) room = models.CharField(max_length=100, blank=True)
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 FreeTextQuestion(Question): TYPE_CODE = 0 TYPE_RICHTEXT = 1 text_type = models.IntegerField( _('Text type'), choices=[ (TYPE_CODE, _('Code')), (TYPE_RICHTEXT, _('Rich text')), ], default=TYPE_CODE, ) syntax_highlight = models.CharField( choices=[x.split(':') for x in formats], default='python', help_text=_('Syntax highlight for code based questions.'), )
class TeamABC(models.TimeStampedModel): """ Common functionality between Pair and Team. """ name = models.CharField(default=phrases.phrase) activity_ctype = models.ForeignKey(models.ContentType, blank=True, null=True) activity_id = models.IntegerField(blank=True, null=True) class Meta: abstract = True @property def activity(self): "Instantiated activity" return self.activity_ctype.get_object_for_this_type( id=self.activity_id)
class Post(models.TimeStampedModel, models.PolymorphicModel): """ Represents a post in the user time-line. """ VISIBILITY_PUBLIC = 1 VISIBILITY_FRIENDS = 0 VISIBILITY_OPTIONS = [ (VISIBILITY_FRIENDS, _('Friends only')), (VISIBILITY_PUBLIC, _('Pubic')), ] user = models.ForeignKey(models.User) text = models.RichTextField() visibility = models.IntegerField(choices=VISIBILITY_OPTIONS, default=VISIBILITY_FRIENDS) def __str__(self): return 'Post by %s at %s' % (self.user, self.created)
class Task(models.Model): """ A task that can be on the backlog or on a sprint. """ STATUS_BACKLOG = 0 STATUS_TODO = 1 STATUS_DOING = 2 STATUS_DONE = 3 STATUS = models.Choices( (STATUS_BACKLOG, 'backlog'), (STATUS_TODO, 'todo'), (STATUS_DOING, 'doing'), (STATUS_DONE, 'done'), ) sprint = models.ForeignKey(Sprint, related_name='tasks') project = models.ForeignKey(ScrumProject, related_name='tasks') status = models.StatusField() created_by = models.ForeignKey(models.User, related_name='+') assigned_to = models.ManyToManyField(models.User, related_name='+') description = models.RichTextField() duration_hours = models.IntegerField() objects = TaskQuerySet.as_manager()
class Activity(CommitMixin, 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. """ VISIBILITY_PRIVATE, VISIBILITY_STAFF, VISIBILITY_PUBLIC = range(3) VISIBILITY_CHOICES = [(VISIBILITY_PRIVATE, _('Private')), (VISIBILITY_STAFF, _('STAFF')), (VISIBILITY_PUBLIC, _('Public'))] owner = models.ForeignKey( models.User, verbose_name=_('Owner'), help_text=_('The activity\'s owner.'), ) visibility = models.PositiveSmallIntegerField( _('Visibility'), choices=VISIBILITY_CHOICES, help_text=_('Makes activity invisible to users.'), ) closed = models.BooleanField( _('Closed to submissions'), default=bool, help_text=_( 'A closed activity does not accept new submissions, but users can ' 'see that they still exist.')) group_submission = models.BooleanField( _('Group submissions'), default=bool, help_text=_( 'If enabled, submissions are registered to groups instead of ' 'individual students.')) max_group_size = models.IntegerField( _('Maximum group size'), default=6, help_text=_( 'If group submission is enabled, define the maximum size of a ' 'group.'), ) disabled = models.BooleanField( _('Disabled'), default=bool, help_text=_( 'Activities can be automatically disabled when Codeshool ' 'encounters an error. This usually produces a message saved on ' 'the .disabled_message attribute. ' 'This field is not controlled directly by users.')) disabled_message = models.TextField( _('Disabled message'), blank=True, help_text=_('Messsage explaining why the activity was disabled.')) has_submissions = models.BooleanField(default=bool) has_correct_submissions = models.BooleanField(default=bool) section_title = property(lambda self: _(self._meta.verbose_name)) objects = ActivityManager() rules = Rules() class Meta: abstract = True verbose_name = _('activity') verbose_name_plural = _('activities') permissions = [ ('interact', 'Interact'), ('view_submissions', 'View submissions'), ] # These properties dynamically define the progress/submission/feedback # classes associated with the current class. progress_class = AuxiliaryClassIntrospection('progress') submission_class = AuxiliaryClassIntrospection('submission') feedback_class = AuxiliaryClassIntrospection('feedback') @property def submissions(self): return self.submission_class.objects.filter( progress__activity_page_id=self.id) def clean(self): super().clean() if not self.author_name and self.owner: name = self.owner.get_full_name() email = self.owner.email self.author_name = '%s <%s>' % (name, email) if self.disabled: raise ValidationError(self.disabled_message) def disable(self, error_message=_('Internal error'), commit=True): """ Disable activity. Args: message: An error message explaining why activity was disabled. """ self.disabled = True self.disabled_message = error_message self.commit(commit, update_fields=['disabled', 'disabled_message']) def submit(self, request, _commit=True, **kwargs): """ Create a new Submission object for the given question and saves it on the database. Args: request: The request object for the current submission. The user is obtained from the request object. This code loads the :cls:`Progress` object for the given user and calls it :meth:`Progress.submit`` passing all named arguments to it. Subclasses should personalize the submit() method of the Progress object instead of the one in this class. """ assert hasattr(request, 'user'), 'request do not have a user attr' # Test if activity is active if self.closed or self.disabled: raise RuntimeError('activity is closed to new submissions') # Fetch submission class submission_class = self.submission_class if submission_class is None: raise ImproperlyConfigured( '%s must define a submission_class attribute with the ' 'appropriate submission class.' % self.__class__.__name__) # Dispatch to the progress object user = request.user logger.info('%r, submission from user %r' % (self.title, user.username)) progress = self.progress_set.for_user(user) return progress.submit(request, kwargs, commit=_commit) def filter_user_submission_payload(self, request, payload): """ Filter a dictionary of arguments supplied by an user and return a dictionary with only those arguments that should be passed to the .submit() function. """ data_fields = self.submission_class.data_fields() return {k: v for (k, v) in payload.items() if k in data_fields} def submit_with_user_payload(self, request, payload): """ Return a submission from a dictionary of user provided kwargs. It first process the keyword arguments and pass them to the .submit() method. """ payload = self.filter_user_submission_payload(request, payload) return self.submit(request, **payload)
class Submission(HasProgressMixin, models.CopyMixin, models.TimeStampedModel, models.PolymorphicModel): """ Represents a student's simple submission in response to some activity. """ class Meta: verbose_name = _('submission') verbose_name_plural = _('submissions') progress = models.ForeignKey('Progress', related_name='submissions') hash = models.CharField(max_length=32, blank=True) ip_address = models.CharField(max_length=20, blank=True) num_recycles = models.IntegerField(default=0) recycled = False has_feedback = property(lambda self: hasattr(self, 'feedback')) objects = SubmissionManager() # Delegated properties @property def final_grade_pc(self): if self.has_feedback: return None return self.feedback.final_grade_pc @property def feedback_class(self): name = self.__class__.__name__.replace('Submission', 'Feedback') return apps.get_model(self._meta.app_label, name) def __repr__(self): return '<%s: %s>' % (self.__class__.__name__, self) def __str__(self): base = '%s by %s' % (self.activity_title, self.sender_username) # if self.feedback_set.last(): # points = self.final_feedback_pc.given_grade # base += ' (%s%%)' % points return base def save(self, *args, **kwargs): if not self.hash: self.hash = self.compute_hash() super().save(*args, **kwargs) def compute_hash(self): """ Computes a hash of data to deduplicate submissions. """ raise ImproperlyConfigured( 'Submission subclass must implement the compute_hash() method.') def auto_feedback(self, silent=False): """ Performs automatic grading and return the feedback object. Args: silent: Prevents the submission_graded_signal from triggering in the end of a successful grading. """ feedback = self.feedback_class(submission=self, manual_grading=False) feedback.update_autograde() feedback.update_final_grade() feedback.save() self.progress.register_feedback(feedback) self.register_feedback(feedback) # Send signal if not silent: submission_graded_signal.send(Submission, submission=self, feedback=feedback, automatic=True) return feedback def register_feedback(self, feedback, commit=True): """ Update itself when a new feedback becomes available. This method should not update the progress instance. """ self.final_feedback = feedback if commit: self.save() def bump_recycles(self): """ Increase the recycle count by one. """ self.num_recycles += 1 self.save(update_fields=['num_recycles']) def is_equal(self, other): """ Check both submissions are equal/equivalent to each other. """ if self.hash == other.hash and self.hash is not None: return True return self.submission_data() == other.submission_data() def submission_data(self): """ Return a dictionary with data specific for submission. It ignores metadata such as creation and modification times, number of recycles, etc. This method should only return data relevant to grading the submission. """ blacklist = { 'id', 'num_recycles', 'ip_address', 'created', 'modified', 'hash', 'final_feedback_id', 'submission_ptr_id', 'polymorphic_ctype_id', } def forbidden_attr(k): return k.startswith('_') or k in blacklist return { k: v for k, v in self.__dict__.items() if not forbidden_attr(k) } def autograde_value(self, *args, **kwargs): """ This method should be implemented in subclasses. """ raise ImproperlyConfigured( 'Progress subclass %r must implement the autograde_value().' 'This method should perform the automatic grading and return the ' 'resulting grade. Any additional relevant feedback data might be ' 'saved to the `feedback_data` attribute, which is then is pickled ' 'and saved into the database.' % type(self).__name__) def manual_grade(self, grade, commit=True, raises=False, silent=False): """ Saves result of manual grading. Args: grade (number): Given grade, as a percentage value. commit: If false, prevents saving the object when grading is complete. The user must save the object manually after calling this method. raises: If submission has already been graded, raises a GradingError. silent: Prevents the submission_graded_signal from triggering in the end of a successful grading. """ if self.status != self.STATUS_PENDING and raises: raise GradingError('Submission has already been graded!') raise NotImplementedError('TODO') def update_progress(self, commit=True): """ Update all parameters for the progress object. Return True if update was required or False otherwise. """ update = False progress = self.progress if self.is_correct and not progress.is_correct: update = True progress.is_correct = True if self.given_grade_pc > progress.best_given_grade_pc: update = True fmt = self.description, progress.best_given_grade_pc, self.given_grade_pc progress.best_given_grade_pc = self.given_grade_pc logger.info('(%s) grade: %s -> %s' % fmt) if progress.best_given_grade_pc > progress.grade: old = progress.grade new = progress.grade = progress.best_given_grade_pc logger.info('(%s) grade: %s -> %s' % (progress.description, old, new)) if commit and update: progress.save() return update def regrade(self, method, commit=True): """ Recompute the grade for the given submission. If status != 'done', it simply calls the .autograde() method. Otherwise, it accept different strategies for updating to the new grades: 'update': Recompute the grades and replace the old values with the new ones. Only saves the submission if the feedback_data or the given_grade_pc attributes change. 'best': Only update if the if the grade increase. 'worst': Only update if the grades decrease. 'best-feedback': Like 'best', but updates feedback_data even if the grades change. 'worst-feedback': Like 'worst', but updates feedback_data even if the grades change. Return a boolean telling if the regrading was necessary. """ if self.status != self.STATUS_DONE: return self.auto_feedback() # We keep a copy of the state, if necessary. We only have to take some # action if the state changes. def rollback(): self.__dict__.clear() self.__dict__.update(state) state = self.__dict__.copy() self.auto_feedback(force=True, commit=False) # Each method deals with the new state in a different manner if method == 'update': if state != self.__dict__: if commit: self.save() return False return True elif method in ('best', 'best-feedback'): if self.given_grade_pc <= state.get('given_grade_pc', 0): new_feedback_data = self.feedback_data rollback() if new_feedback_data != self.feedback_data: self.feedback_data = new_feedback_data if commit: self.save() return True return False elif commit: self.save() return True elif method in ('worst', 'worst-feedback'): if self.given_grade_pc >= state.get('given_grade_pc', 0): new_feedback_data = self.feedback_data rollback() if new_feedback_data != self.feedback_data: self.feedback_data = new_feedback_data if commit: self.save() return True return False elif commit: self.save() return True else: rollback() raise ValueError('invalid method: %s' % method) def get_feedback_title(self): """ Return the title for the feedback message. """ try: feedback = self.feedback except AttributeError: return _('Not graded') else: return feedback.get_feedback_title()
class Progress(models.CopyMixin, models.StatusModel, models.TimeStampedModel, models.PolymorphicModel): """ When an user starts an activity it opens a Progress object which control all submissions to the given activity. The Progress object also manages individual submissions that may span several http requests. """ class Meta: unique_together = [('user', 'activity_page')] verbose_name = _('student progress') verbose_name_plural = _('student progress list') STATUS_OPENED = 'opened' STATUS_CLOSED = 'closed' STATUS_INCOMPLETE = 'incomplete' STATUS_WAITING = 'waiting' STATUS_INVALID = 'invalid' STATUS_DONE = 'done' STATUS = models.Choices( (STATUS_OPENED, _('opened')), (STATUS_CLOSED, _('closed')), ) user = models.ForeignKey(models.User, on_delete=models.CASCADE) activity_page = models.ForeignKey(models.Page, on_delete=models.CASCADE) final_grade_pc = models.DecimalField( _('final score'), max_digits=6, decimal_places=3, default=Decimal, help_text=_( 'Final grade given to considering all submissions, penalties, etc.' ), ) given_grade_pc = models.DecimalField( _('grade'), max_digits=6, decimal_places=3, default=Decimal, help_text=_('Final grade before applying any modifier.'), ) finished = models.DateTimeField(blank=True, null=True) best_submission = models.ForeignKey('Submission', blank=True, null=True, related_name='+') points = models.IntegerField(default=0) score = models.IntegerField(default=0) stars = models.FloatField(default=0.0) is_correct = models.BooleanField(default=bool) has_submissions = models.BooleanField(default=bool) has_feedback = models.BooleanField(default=bool) has_post_tests = models.BooleanField(default=bool) objects = ProgressManager() #: The number of submissions num_submissions = property(lambda x: x.submissions.count()) #: Specific activity reference activity = property(lambda x: x.activity_page.specific) activity_id = property(lambda x: x.activity_page_id) #: Has progress mixin interface username = property(lambda x: x.user.username) def __repr__(self): return '<%s: %s>' % (self.__class__.__name__, self) def __str__(self): tries = self.num_submissions user = self.user activity = self.activity grade = '%s pts' % (self.final_grade_pc or 0) fmt = '%s by %s (%s, %s tries)' return fmt % (activity, user, grade, tries) def __hash__(self): return hash(self.id) def __eq__(self, other): if isinstance(other, Progress): if self.pk is None: return False else: return self.pk == other.pk return NotImplemented def submit(self, request, recycle=True, **kwargs): """ Creates new submission. """ submission_class = self.activity.submission_class submission = submission_class(progress=self, **kwargs) submission.ip_address = get_ip(request) if not recycle: submission.save() return submission # Collect all submissions with the same hash as current one recyclable = submission_class.objects\ .filter(progress=self, hash=submission.compute_hash()) \ .order_by('created') # Then check if any submission is actually equal to the current amongst # all candidates for possibly_equal in recyclable: if submission.is_equal(possibly_equal): possibly_equal.recycled = True possibly_equal.bump_recycles() return possibly_equal else: submission.save() return submission def register_feedback(self, feedback): """ This method is called after a submission is graded and produces a feedback. """ submission = feedback.submission self.update_grades_from_feedback(feedback) if not self.activity.has_submissions: print('first submission') if feedback.is_correct: print('first correct submission') def update_grades_from_feedback(self, feedback): """ Update grades from the current progress object from the given feedback. """ # Update grades if self.given_grade_pc < (feedback.given_grade_pc or 0): self.given_grade_pc = feedback.given_grade_pc # TODO: decide better update strategy if self.final_grade_pc < feedback.final_grade_pc: self.final_grade_pc = feedback.final_grade_pc # # Register points and stars associated with submission. # score_kwargs = {} # final_points = feedback.final_points() # final_stars = feedback.final_stars() # if final_points > self.points: # score_kwargs['points'] = final_points - self.points # self.points = final_points # if final_stars > self.stars: # score_kwargs['stars'] = final_stars - self.stars # self.stars = final_stars # # # If some score has changed, we save the update fields and update the # # corresponding UserScore object # if score_kwargs: # from codeschool.gamification.models import UserScore # self.save(update_fields=score_kwargs.keys()) # score_kwargs['diff'] = True # UserScore.update(self.user, self.activity_page, **score_kwargs) # Update the is_correct field self.is_correct = self.is_correct or feedback.is_correct self.save() def update_from_submissions(self, grades=True, score=True, commit=True, refresh=False): """ Update grades and gamification scores for all submissions. Args: grades, score (bool): choose to update final grades and/or final scores. commit: if True (default), save changes to database. refresh: if True (default), recompute grade from scratch. """ submissions = self.submissions.all() if refresh and submissions.count(): first = submissions.first() if grades: self.final_grade_pc = first.given_grade_pc self.given_grade_pc = first.given_grade_pc if score: self.points = first.points self.stars = first.stars self.score = first.score for submission in submissions: if grades: submission.update_response_grades(commit=False) if score: submission.update_response_score(commit=False) if commit: self.save() def regrade(self, method=None, force_update=False): """ Return the final grade for the user using the given method. If not method is given, it uses the default grading method for the activity. """ activity = self.activity # Choose grading method if method is None and self.final_grade_pc is not None: return self.final_grade_pc elif method is None: grading_method = activity.grading_method else: grading_method = GradingMethod.from_name(activity.owner, method) # Grade response. We save the result to the final_grade_pc attribute if # no explicit grading method is given. grade = grading_method.grade(self) if method is None and (force_update or self.final_grade_pc is None): self.final_grade_pc = grade return grade
class AttendanceSheet(models.Model): """ Controls student attendance by generating a new public passphrase under teacher request. Students confirm attendance by typing the secret phrase in a small interval. """ max_attempts = models.SmallIntegerField(default=3) expiration_minutes = models.SmallIntegerField(default=5) owner = models.ForeignKey(models.User) last_event = models.ForeignKey('Event', blank=True, null=True) max_string_distance = models.SmallIntegerField(default=0) max_number_of_absence = models.IntegerField(blank=True, null=True) @property def expiration_interval(self): return datetime.timedelta(minutes=self.expiration_minutes) @property def attendance_checks(self): return AttendanceCheck.objects.filter(event__sheet=self) def new_event(self): """ Create a new event in attendance sheet. """ current_time = now() new = self.events.create(passphrase=new_random_passphrase(), date=current_time.date(), created=current_time, expires=current_time + self.expiration_interval) self.last_event = new self.save(update_fields=['last_event']) return new def get_today_event(self): """ Return the last event created for today """ if self.last_event.date() == now().date(): return self.last_event else: return self.new_event() def number_of_absences(self, user): """ Return the total number of absence for user. """ return self.attendance_checks.filter(user=user, has_attended=False).count() def absence_table(self, users=None, method='fraction'): """ Return a mapping between users and their respective absence rate. Args: users: A queryset of users. method: One of 'fraction' (default), 'number', 'attendance' or 'attendance-fraction' """ try: get_value_from_absence = { 'fraction': lambda x: x / num_events, 'number': lambda x: x, 'attendance': lambda x: num_events - x, 'attendance-fraction': lambda x: (num_events - x) / num_events }[method] except KeyError: raise ValueError('invalid method: %r' % method) num_events = self.events.count() if users is None: users = models.User.objects.all() result = collections.OrderedDict() for user in users: absence = self.user_absence(user) result[user] = get_value_from_absence(absence) return result def render_dialog(self, request): """ Renders attendance dialog based on request. """ context = { 'passphrase': self.passphrase, 'is_expired': self.is_expired(), 'minutes_left': self.minutes_left(raises=False) } user = request.user if user == self.owner: template = 'attendance/edit.jinja2' else: template = 'attendance/view.jinja2' context['attempts'] = self.user_attempts(user) return render_to_string(template, request=request, context=context) def user_attempts(self, user): """ Return the number of user attempts in the last attendance event. """ if self.last_event is None: return 0 qs = self.attendance_checks.filter(user=user, event=self.last_event) return qs.count() def minutes_left(self, raises=True): """ Return how many minutes left for expiration. """ if self.last_event: time = now() if self.last_event.expires < time: return 0.0 else: dt = self.last_event.expires - time return dt.minutes if raises: raise ValueError('last event is not defined') else: return None def is_expired(self): """ Return True if last_event has already expired. """ if not self.last_event: return False return self.last_event.expires < now()
class Submission(ResponseDataMixin, FeedbackDataMixin, models.CopyMixin, models.StatusModel, models.TimeStampedModel, models.PolymorphicModel): """ Represents a student's simple submission in response to some activity. Submissions can be in 4 different states: pending: The response has been sent, but was not graded. Grading can be manual or automatic, depending on the activity. waiting: Waiting for manual feedback. incomplete: For long-term activities, this tells that the student started a response and is completing it gradually, but the final response was not achieved yet. invalid: The response has been sent, but contains malformed data. done: The response was graded and evaluated and it initialized a feedback object. A response always starts at pending status. We can request it to be graded by calling the :func:`Response.autograde` method. This method must raise an InvalidResponseError if the response is invalid or ManualGradingError if the response subclass does not implement automatic grading. """ class Meta: verbose_name = _('submission') verbose_name_plural = _('submissions') # Feedback messages MESSAGE_OK = _('*Congratulations!* Your response is correct!') MESSAGE_OK_WITH_PENALTIES = _( 'Your response is correct, but you did not achieved the maximum grade.' ) MESSAGE_WRONG = _('I\'m sorry, your response is wrong.') MESSAGE_PARTIAL = _( 'Your answer is partially correct: you achieved only %(grade)d%% of ' 'the total grade.') MESSAGE_NOT_GRADED = _('Your response has not been graded yet!') # Status STATUS_PENDING = 'pending' STATUS_INCOMPLETE = 'incomplete' STATUS_WAITING = 'waiting' STATUS_INVALID = 'invalid' STATUS_DONE = 'done' # Fields STATUS = models.Choices( (STATUS_PENDING, _('pending')), (STATUS_INCOMPLETE, _('incomplete')), (STATUS_WAITING, _('waiting')), (STATUS_INVALID, _('invalid')), (STATUS_DONE, _('done')), ) response = models.ParentalKey( 'Response', related_name='submissions', ) given_grade = models.DecimalField( _('percentage of maximum grade'), help_text=_( 'This grade is given by the auto-grader and represents the grade ' 'for the response before accounting for any bonuses or penalties.' ), max_digits=6, decimal_places=3, blank=True, null=True, ) final_grade = models.DecimalField( _('final grade'), help_text=_( 'Similar to given_grade, but can account for additional factors ' 'such as delay penalties or for any other reason the teacher may ' 'want to override the student\'s grade.'), max_digits=6, decimal_places=3, blank=True, null=True, ) manual_override = models.BooleanField(default=False) points = models.IntegerField(default=0) score = models.IntegerField(default=0) stars = models.FloatField(default=0) objects = SubmissionManager() # Status properties is_done = property(lambda x: x.status == x.STATUS_DONE) is_pending = property(lambda x: x.status == x.STATUS_PENDING) is_waiting = property(lambda x: x.status == x.STATUS_WAITING) is_invalid = property(lambda x: x.status == x.STATUS_INVALID) @property def is_correct(self): if self.given_grade is None: raise AttributeError('accessing attribute of non-graded response.') else: return self.given_grade == 100 # Delegate properties activity = delegate_to('response') activity_id = delegate_to('response') activity_page = delegate_to('response') activity_page_id = delegate_to('response') user = delegate_to('response') user_id = delegate_to('response') stars_total = delegate_to('activity') points_total = delegate_to('activity') @classmethod def response_data_hash(cls, response_data): """ Computes a hash for the response_data attribute. Data must be given as a JSON-like structure or as a string of JSON data. """ if response_data: if isinstance(response_data, str): data = response_data else: data = json.dumps(response_data, default=json_default) return md5hash(data) return '' def __init__(self, *args, **kwargs): # Django is loading object from the database -- we step out the way if args and not kwargs: super().__init__(*args, **kwargs) return # We create the response_data and feedback_data manually always using # copies of passed dicts. We save these variables here, init object and # then copy this data to the initialized dictionaries response_data = kwargs.pop('response_data', None) or {} feedback_data = kwargs.pop('feedback_data', None) or {} # This part makes a Submission instance initialize from a user + # activity instead of requiring a response object. The response is # automatically created on demand. user = kwargs.pop('user', None) if 'response' in kwargs and user and user != kwargs['response'].user: response_user = kwargs['response'].user raise ValueError('Inconsistent user definition: %s vs. %s' % (user, response_user)) elif 'response' not in kwargs and user: try: activity = kwargs.pop('activity') except KeyError: raise TypeError( '%s objects bound to a user must also provide an ' 'activity parameter.' % type(self).__name__) else: # User-bound constructor tries to obtain the response object by # searching for an specific (user, activity) tuple. response, created = Response.objects.get_or_create( user=user, activity=activity) kwargs['response'] = response if 'context' in kwargs or 'activity' in kwargs: raise TypeError( 'Must provide an user to instantiate a bound submission.') super().__init__(*args, **kwargs) # Now that we have initialized the submission, we fill the data # passed in the response_data and feedback_data dictionaries. self.response_data = dict(self.response_data or {}, **response_data) self.feedback_data = dict(self.response_data or {}, **feedback_data) def __str__(self): if self.given_grade is None: grade = self.status else: grade = '%s pts' % self.final_grade user = self.user activity = self.activity name = self.__class__.__name__ return '<%s: %s by %s (%s)>' % (name, activity, user, grade) def __html__(self): """ A string of html source representing the feedback. """ if self.is_done: data = {'grade': (self.final_grade or 0)} if self.final_grade == 100: return markdown(self.MESSAGE_OK) elif self.given_grade == 100: return markdown(self.ok_with_penalties_message) elif not self.given_grade: return markdown(self.MESSAGE_WRONG) else: return markdown(self.MESSAGE_PARTIAL % data) else: return markdown(self.MESSAGE_NOT_GRADED) def save(self, *args, **kwargs): if not self.response_hash: self.response_hash = self.response_hash_from_data( self.response_hash) super().save(*args, **kwargs) def final_points(self): """ Return the amount of points awarded to the submission after considering all penalties and bonuses. """ return self.points def final_stars(self): """ Return the amount of stars awarded to the submission after considering all penalties and bonuses. """ return self.stars def given_stars(self): """ Compute the number of stars that should be awarded to the submission without taking into account bonuses and penalties. """ return self.stars_total * (self.given_grade / 100) def given_points(self): """ Compute the number of points that should be awarded to the submission without taking into account bonuses and penalties. """ return int(self.points_total * (self.given_grade / 100)) def feedback(self, commit=True, force=False, silent=False): """ Return the feedback object associated to the given response. This method may trigger the autograde() method, if grading was not performed yet. If you want to defer database access, call it with commit=False to prevent saving any modifications to the response object to the database. The commit, force and silent arguments have the same meaning as in the :func:`Submission.autograde` method. """ if self.status == self.STATUS_PENDING: self.autograde(commit=commit, force=force, silent=silent) elif self.status == self.STATUS_INVALID: raise self.feedback_data elif self.status == self.STATUS_WAITING: return None return self.feedback_data def autograde(self, commit=True, force=False, silent=False): """ Performs automatic grading. Response subclasses must implement the autograde_compute() method in order to make automatic grading work. This method may write any relevant information to the `feedback_data` attribute and must return a numeric value from 0 to 100 with the given automatic grade. Args: commit: If false, prevents saving the object when grading is complete. The user must save the object manually after calling this method. force: If true, force regrading the item even if it has already been graded. The default behavior is to ignore autograde from a graded submission. silent: Prevents the submission_graded_signal from triggering in the end of a successful grading. """ if self.status == self.STATUS_PENDING or force: # Evaluate grade using the autograde_value() method of subclass. try: value = self.autograde_value() except self.InvalidSubmissionError as ex: self.status = self.STATUS_INVALID self.feedback_data = ex self.given_grade = self.final_grade = decimal.Decimal(0) if commit: self.save() raise # If no value is returned, change to STATUS_WAITING. This probably # means that response is partial and we need other submissions to # complete the final response if value is None: self.status = self.STATUS_WAITING # A regular submission has a decimal grade value. We save it and # change state to STATUS_DONE else: self.given_grade = decimal.Decimal(value) if self.final_grade is None: self.final_grade = self.given_grade self.status = self.STATUS_DONE # Commit results if commit and self.pk: self.save(update_fields=[ 'status', 'feedback_data', 'given_grade', 'final_grade' ]) elif commit: self.save() # If STATUS_DONE, we submit the submission_graded signal. if self.status == self.STATUS_DONE: self.stars = self.given_stars() self.points = self.given_points() self.response.register_submission(self) if not silent: submission_graded_signal.send( Submission, submission=self, given_grade=self.given_grade, automatic=True, ) elif self.status == self.STATUS_INVALID: raise self.feedback_data def manual_grade(self, grade, commit=True, raises=False, silent=False): """ Saves result of manual grading. Args: grade (number): Given grade, as a percentage value. commit: If false, prevents saving the object when grading is complete. The user must save the object manually after calling this method. raises: If submission has already been graded, raises a GradingError. silent: Prevents the submission_graded_signal from triggering in the end of a successful grading. """ if self.status != self.STATUS_PENDING and raises: raise GradingError('Submission has already been graded!') raise NotImplementedError('TODO') def autograde_value(self): """ This method should be implemented in subclasses. """ raise ImproperlyConfigured( 'Response subclass %r must implement the autograde_value().' 'This method should perform the automatic grading and return the ' 'resulting grade. Any additional relevant feedback data might be ' 'saved to the `feedback_data` attribute, which is then is pickled ' 'and saved into the database.' % type(self).__name__) def regrade(self, method, commit=True): """ Recompute the grade for the given submission. If status != 'done', it simply calls the .autograde() method. Otherwise, it accept different strategies for updating to the new grades: 'update': Recompute the grades and replace the old values with the new ones. Only saves the submission if the feedback_data or the given_grade attributes change. 'best': Only update if the if the grade increase. 'worst': Only update if the grades decrease. 'best-feedback': Like 'best', but updates feedback_data even if the grades change. 'worst-feedback': Like 'worst', but updates feedback_data even if the grades change. Return a boolean telling if the regrading was necessary. """ if self.status != self.STATUS_DONE: return self.autograde() # We keep a copy of the state, if necessary. We only have to take some # action if the state changes. def rollback(): self.__dict__.clear() self.__dict__.update(state) state = self.__dict__.copy() self.autograde(force=True, commit=False) # Each method deals with the new state in a different manner if method == 'update': if state != self.__dict__: if commit: self.save() return False return True elif method in ('best', 'best-feedback'): if self.given_grade <= state.get('given_grade', 0): new_feedback_data = self.feedback_data rollback() if new_feedback_data != self.feedback_data: self.feedback_data = new_feedback_data if commit: self.save() return True return False elif commit: self.save() return True elif method in ('worst', 'worst-feedback'): if self.given_grade >= state.get('given_grade', 0): new_feedback_data = self.feedback_data rollback() if new_feedback_data != self.feedback_data: self.feedback_data = new_feedback_data if commit: self.save() return True return False elif commit: self.save() return True else: rollback() raise ValueError('invalid method: %s' % method)
class QuizActivity(Activity): """ Represent a quiz. """ class Meta: verbose_name = _('quiz activity') verbose_name_plural = _('quiz activities') GRADING_METHOD_MAX = 0 GRADING_METHOD_MIN = 1 GRADING_METHOD_AVERAGE = 2 GRADING_METHOD_CHOICES = ( (GRADING_METHOD_MAX, _('largest grade of all responses')), (GRADING_METHOD_MIN, _('smallest grade of all responses')), (GRADING_METHOD_AVERAGE, _('mean grade')), ) quiz_grading_method = models.IntegerField(choices=GRADING_METHOD_CHOICES) language = models.ForeignKey(ProgrammingLanguage, blank=True, null=True) # Derived attributes items = QuizActivityItem.as_items() questions = property(lambda x: list(x)) num_questions = property(lambda x: len(x.items)) def __iter__(self): return (x.question.as_subclass() for x in self.items) def __len__(self): return len(self.items) def __getitem__(self, idx): return self.items[idx].question def __delitem__(self, idx): del self.items[idx] def add_question(self, question): """Add a question to the quiz.""" item = QuizActivityItem(quiz=self, question=question) item.save() self.items.append(item) def get_user_response(self, user): """Return a response object for the given user. For now, users can only have one response .""" try: response = self.responses.filter(user=user, parent__isnull=True).first() if response: return response.quizresponse else: raise Response.DoesNotExist except Response.DoesNotExist: new = QuizResponse.objects.create(activity=self, user=user) return new def register_response(self, user, response, commit=True): """Register a question response to the given user.""" self.get_user_response(user).register_response(response, commit) def iter_tagged_questions(self, tag='answered', user=None): """Iterate over tuples of (question, question) where tag is some property associated with each question. Args: tag: Some property pertaining to the question. It accept the following string values: answered: A boolean value telling if the question has been answered by the user. user: The user associated with the tag. This may be necessary to some tags. """ if tag == 'answered': response = self.get_user_response(user) for question in self.questions: yield (question, response.is_answered(question)) else: return NotImplemented def get_final_grade(self, user): """Return the final grade for the given user.""" response = self.get_user_response(user) return response.get_final_grade()
class KeyValuePair(models.Model): """ Represents a (key, value) pair datum. """ class Meta: abstract = True name = models.CharField(max_length=30, unique=True) value = models.CharField(max_length=100) type = models.IntegerField(choices=[ (0, 'str'), (1, 'int'), (2, 'float'), (3, 'bool'), ]) @property def data(self): raw_data = self.value if self.type == 0: return raw_data elif self.type == 1: return int(raw_data) elif self.type == 2: return float(raw_data) elif self.type == 3: return bool(int(raw_data)) else: raise ValueError(self.type) @classmethod def serialize(cls, value): """ Return string representation of value. """ try: return { int: str, float: str, str: lambda x: x, bool: lambda x: str(int(x)) }[type(value)](value) except KeyError: type_name = value.__class__.__name__ raise TypeError('invalid config value type: %r' % type_name) @classmethod def data_type(cls, value): """ Return data type for value. """ try: return {str: 0, int: 1, float: 2, bool: 3}[type(value)] except KeyError: type_name = value.__class__.__name__ raise TypeError('invalid config value type: %r' % type_name) def __str__(self): return self.name
class Submission(CommitMixin, FromProgressAttributesMixin, models.CopyMixin, models.TimeStampedModel, models.PolymorphicModel): """ Represents a student's simple submission in response to some activity. """ progress = models.ForeignKey('Progress', related_name='submissions') hash = models.CharField(max_length=32) ip_address = models.CharField(max_length=20, blank=True) num_recycles = models.IntegerField(default=0) recycled = False class Meta: verbose_name = _('submission') verbose_name_plural = _('submissions') # Properties has_feedback = property(lambda self: hasattr(self, 'feedback')) objects = SubmissionManager() # Delegated properties @property def final_grade_pc(self): if self.has_feedback: return None return self.feedback.final_grade_pc @property def feedback_class(self): name = self.__class__.__name__.replace('Submission', 'Feedback') return apps.get_model(self._meta.app_label, name) @classmethod def data_fields(cls): """ Return a list of attributes that store submission data. It ignores metadata such as creation and modification times, number of recycles, etc. This method should only return fields relevant to grading the submission. """ blacklist = { 'id', 'num_recycles', 'ip_address', 'created', 'modified', 'hash', 'final_feedback_id', 'submission_ptr_id', 'polymorphic_ctype_id', 'progress_id', } fields = [field.attname for field in cls._meta.fields] return [field for field in fields if field not in blacklist] def __repr__(self): return '<%s: %s>' % (self.__class__.__name__, self) def __str__(self): username = self.user.username base = '%s by %s' % (self.activity_title, username) return base def clean(self): if not self.hash: self.hash = self.compute_hash() super().clean() def compute_hash(self): """ Computes a hash of data to deduplicate submissions. """ fields = get_default_fields(type(self)) return md5hash(';'.join(map(lambda f: str(getattr(self, f)), fields))) def auto_feedback(self, silent=False, commit=True): """ Performs automatic grading and return the feedback object. Args: silent: Prevents the submission_graded_signal from triggering in the end of a successful grading. """ # Create feedback object feedback = self.feedback_class(submission=self, manual_grading=False) feedback.given_grade_pc, state = feedback.get_autograde_value() feedback.is_correct = feedback.given_grade_pc == 100 update_state(feedback, state) feedback.final_grade_pc = feedback.get_final_grade_value() feedback.commit(commit) # Register graded feedback self.register_feedback(feedback) # Send signal if not silent: submission_graded_signal.send(Submission, submission=self, feedback=feedback, automatic=True) return feedback def is_equal(self, other): """ Check both submissions are equal/equivalent to each other. """ if self.hash != other.hash and self.hash and other.hash: return False fields = get_default_fields(type(self)) return all(getattr(self, f) == getattr(other, f) for f in fields) def bump_recycles(self): """ Increase the recycle count by one. """ self.num_recycles += 1 self.save(update_fields=['num_recycles']) def register_feedback(self, feedback, commit=True): """ Update itself when a new feedback becomes available. This method should not update the progress instance. """ # Call the register feedback of the progress object self.progress.register_feedback(feedback) def manual_grade(self, grade, commit=True, raises=False, silent=False): """ Saves result of manual grading. Args: grade (number): Given grade, as a percentage value. commit: If false, prevents saving the object when grading is complete. The user must save the object manually after calling this method. raises: If submission has already been graded, raises a GradingError. silent: Prevents the submission_graded_signal from triggering in the end of a successful grading. """ if self.status != self.STATUS_PENDING and raises: raise GradingError('Submission has already been graded!') raise NotImplementedError('TODO') def get_feedback_title(self): """ Return the title for the feedback message. """ try: feedback = self.feedback except AttributeError: return _('Not graded') else: return feedback.get_feedback_title()
class Progress(CommitMixin, models.CopyMixin, models.StatusModel, models.TimeStampedModel, models.PolymorphicModel): """ When an user starts an activity it opens a Progress object which control all submissions to the given activity. The Progress object also manages individual submissions that may span several http requests. """ class Meta: unique_together = [('user', 'activity_page')] verbose_name = _('student progress') verbose_name_plural = _('student progress list') STATUS_OPENED = 'opened' STATUS_CLOSED = 'closed' STATUS_INCOMPLETE = 'incomplete' STATUS_WAITING = 'waiting' STATUS_INVALID = 'invalid' STATUS_DONE = 'done' STATUS = models.Choices( (STATUS_OPENED, _('opened')), (STATUS_CLOSED, _('closed')), ) user = models.ForeignKey(models.User, on_delete=models.CASCADE) activity_page = models.ForeignKey(models.Page, on_delete=models.CASCADE) final_grade_pc = models.DecimalField( _('final score'), max_digits=6, decimal_places=3, default=Decimal, help_text=_( 'Final grade given to considering all submissions, penalties, etc.' ), ) given_grade_pc = models.DecimalField( _('grade'), max_digits=6, decimal_places=3, default=Decimal, help_text=_('Final grade before applying any modifier.'), ) finished = models.DateTimeField(blank=True, null=True) best_submission = models.ForeignKey('Submission', blank=True, null=True, related_name='+') points = models.IntegerField(default=0) score = models.IntegerField(default=0) stars = models.FloatField(default=0.0) is_correct = models.BooleanField(default=bool) has_submissions = models.BooleanField(default=bool) has_feedback = models.BooleanField(default=bool) has_post_tests = models.BooleanField(default=bool) objects = ProgressManager() #: The number of submissions num_submissions = property(lambda x: x.submissions.count()) #: Specific activity reference activity = property(lambda x: x.activity_page.specific) activity_id = property(lambda x: x.activity_page_id) #: Has progress mixin interface username = property(lambda x: x.user.username) def __repr__(self): return '<%s: %s>' % (self.__class__.__name__, self) def __str__(self): tries = self.num_submissions user = self.user activity = self.activity grade = '%s pts' % (self.final_grade_pc or 0) fmt = '%s by %s (%s, %s tries)' return fmt % (activity, user, grade, tries) def submit(self, request, payload, recycle=True, commit=True): """ Creates new submission. Args: 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 the previous submission. If recycled, sets the submission.recycled to True. """ submission_class = self.activity.submission_class submission = submission_class(progress=self, **payload) submission.ip_address = get_ip(request) submission.hash = submission.compute_hash() submission.full_clean() # Then check if any submission is equal to some past submission and # then recycle it recyclable = submission_class.objects.recyclable(submission) recyclable = recyclable if recycle else () for possibly_equal in recyclable: if submission.is_equal(possibly_equal): possibly_equal.recycled = True possibly_equal.bump_recycles() return possibly_equal else: return submission.commit(commit) def register_feedback(self, feedback, commit=True): """ This method is called after a submission is graded and produces a feedback. """ submission = feedback.submission # Check if it is the best submission grade = feedback.given_grade_pc if (self.best_submission is None or self.best_submission.feedback.given_grade_pc < grade): self.best_submission = submission # Update grades for activity considering past submissions self.update_grades_from_feedback(feedback) self.commit(commit) def update_grades_from_feedback(self, feedback): """ Update grades from the current progress object from the given feedback. """ # Update grades, keeping always the best grade if self.given_grade_pc < (feedback.given_grade_pc or 0): self.given_grade_pc = feedback.given_grade_pc if self.final_grade_pc < feedback.final_grade_pc: self.final_grade_pc = feedback.final_grade_pc # Update the is_correct field self.is_correct = self.is_correct or feedback.is_correct
class User(AbstractBaseUser, PermissionsMixin): """ Base user model. """ REQUIRED_FIELDS = ['alias', 'name', 'school_id', 'role'] USERNAME_FIELD = 'email' ROLE_STUDENT, ROLE_TEACHER, ROLE_STAFF, ROLE_ADMIN = range(4) ROLE_CHOICES = [ (ROLE_STUDENT, _('Student')), (ROLE_TEACHER, _('Teacher')), (ROLE_STAFF, _('School staff')), (ROLE_ADMIN, _('Administrator')), ] email = models.EmailField( _('E-mail'), db_index=True, unique=True, help_text=_( 'Users can register additional e-mail addresses. This is the ' 'main e-mail address which is used for login.')) name = models.CharField(_('Name'), max_length=140, help_text=_('Full name of the user.')) alias = models.CharField( _('Alias'), max_length=20, help_text=_('Public alias used to identify the user.')) school_id = models.CharField( _('School id'), max_length=20, blank=False, unique=True, validators=[], # TODO: validate school id number help_text=_('Identification number in your school issued id card.')) role = models.IntegerField( _('Main'), choices=ROLE_CHOICES, default=ROLE_STUDENT, help_text=_('User main role in the codeschool platform.')) is_staff = models.BooleanField( _('staff status'), default=False, help_text=_( 'Designates whether the user can log into this admin site.'), ) is_active = models.BooleanField( _('active'), default=True, help_text=_( 'Designates whether this user should be treated as active. ' 'Unselect this instead of deleting accounts.'), ) date_joined = models.DateTimeField(_('date joined'), default=timezone.now) objects = UserManager() # Temporary properties defined for compatibility username = property(lambda x: x.email) @property def profile(self): if self.id is None: return self._lazy_profile try: return self.profile_ref except AttributeError: self.profile_ref = Profile(user=self) return self.profile_ref @profile.setter def profile(self, value): if self.id is None: self._lazy_profile = value else: self.profile_ref = value @lazy def _lazy_profile(self): return Profile(user=self) def save(self, *args, **kwargs): new = self.id is None if new: with transaction.atomic(): super().save(*args, **kwargs) self.profile.save() else: super().save(*args, **kwargs) def get_full_name(self): return self.name.strip() def get_short_name(self): return self.alias def get_absolute_url(self): return reverse('users:profile-detail', args=(self.id, ))
class Profile(models.TimeStampedModel): """ Social information about users. """ GENDER_MALE, GENDER_FEMALE, GENDER_OTHER = 0, 1, 2 GENDER_CHOICES = [ (GENDER_MALE, _('Male')), (GENDER_FEMALE, _('Female')), (GENDER_OTHER, _('Other')), ] VISIBILITY_PUBLIC, VISIBILITY_FRIENDS, VISIBILITY_HIDDEN = range(3) VISIBILITY_CHOICES = enumerate( [_('Any Codeschool user'), _('Only friends'), _('Private')]) visibility = models.IntegerField( _('Visibility'), choices=VISIBILITY_CHOICES, default=VISIBILITY_FRIENDS, help_text=_('Who do you want to share information in your profile?')) user = models.OneToOneField( User, verbose_name=_('user'), related_name='profile_ref', ) phone = models.CharField( _('Phone'), max_length=20, blank=True, null=True, ) gender = models.SmallIntegerField( _('gender'), choices=GENDER_CHOICES, blank=True, null=True, ) date_of_birth = models.DateField( _('date of birth'), blank=True, null=True, ) website = models.URLField( _('Website'), blank=True, null=True, help_text=_('A website that is shown publicly in your profile.')) about_me = models.RichTextField( _('About me'), blank=True, help_text=_('A small description about yourself.')) # Delegates and properties username = delegate_to('user', True) name = delegate_to('user') email = delegate_to('user') class Meta: permissions = ( ('student', _('Can access/modify data visible to student\'s')), ('teacher', _('Can access/modify data visible only to Teacher\'s')), ) @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_absolute_url(self): self.user.get_absolute_url()
class Response(models.CopyMixin, models.StatusModel, models.TimeStampedModel, models.PolymorphicModel, models.ClusterableModel): """ When an user starts an activity it opens a Session object that controls how responses to the given activity will be submitted. The session object manages individual response submissions that may span several http requests. """ class Meta: unique_together = [('user', 'activity_page')] verbose_name = _('final response') verbose_name_plural = _('final responses') STATUS_OPENED = 'opened' STATUS_CLOSED = 'closed' STATUS_INCOMPLETE = 'incomplete' STATUS_WAITING = 'waiting' STATUS_INVALID = 'invalid' STATUS_DONE = 'done' STATUS = models.Choices( (STATUS_OPENED, _('opened')), (STATUS_CLOSED, _('closed')), ) user = models.ForeignKey( models.User, related_name='responses', on_delete=models.CASCADE, ) activity_page = models.ForeignKey( models.Page, related_name='responses', on_delete=models.CASCADE, ) grade = models.DecimalField( _('given grade'), max_digits=6, decimal_places=3, blank=True, null=True, default=0, help_text=_( 'Grade given to response considering all submissions, penalties, ' 'etc.'), ) finish_time = models.DateTimeField( blank=True, null=True, ) points = models.IntegerField(default=0) score = models.IntegerField(default=0) stars = models.FloatField(default=0.0) is_finished = models.BooleanField(default=bool) is_correct = models.BooleanField(default=bool) objects = ResponseManager() #: The number of submissions in the current session. num_submissions = property(lambda x: x.submissions.count()) #: Specific activity reference activity = property(lambda x: x.activity_page.specific) activity_id = property(lambda x: x.activity_page_id) @activity.setter def activity(self, value): self.activity_page = value.page_ptr @classmethod def _get_response(cls, user, activity): """ Return the response object associated with the given user/activity. Create a new response object if it does not exist. """ if user is None or activity is None: raise TypeError( 'Response objects must be bound to an user or activity.') response, create = Response.objects.get_or_create(user=user, activity=activity) return response def __repr__(self): tries = self.num_submissions user = self.user activity = self.activity class_name = self.__class__.__name__ grade = '%s pts' % (self.grade or 0) fmt = '<%s: %s by %s (%s, %s tries)>' return fmt % (class_name, activity, user, grade, tries) def __str__(self): return repr(self) def __hash__(self): return hash(self.id) def __eq__(self, other): if isinstance(other, Response): if self.pk is None: return False else: return self.pk == other.pk return NotImplemented def register_submission(self, submission): """ This method is called when a submission is graded. """ assert submission.response_id == self.id # Register points and stars associated with submission. score_kwargs = {} final_points = submission.final_points() final_stars = submission.final_stars() if final_points > self.points: score_kwargs['points'] = final_points - self.points self.points = final_points if final_stars > self.stars: score_kwargs['stars'] = final_stars - self.stars self.stars = final_stars # If some score has changed, we save the update fields and update the # corresponding UserScore object if score_kwargs: from codeschool.lms.gamification.models import UserScore self.save(update_fields=score_kwargs.keys()) score_kwargs['diff'] = True UserScore.update(self.user, self.activity_page, **score_kwargs) def regrade(self, method=None, force_update=False): """ Return the final grade for the user using the given method. If not method is given, it uses the default grading method for the activity. """ activity = self.activity # Choose grading method if method is None and self.final_grade is not None: return self.final_grade elif method is None: grading_method = activity.grading_method else: grading_method = GradingMethod.from_name(activity.owner, method) # Grade response. We save the result to the final_grade attribute if # no explicit grading method is given. grade = grading_method.grade(self) if method is None and (force_update or self.final_grade is None): self.final_grade = grade return grade
class ScoreHandler(models.TimeStampedModel): """ Common implementations for TotalScores and UserScores. """ class Meta: abstract = True page = models.ForeignKey(models.Page, related_name='+') points = models.IntegerField(default=0) stars = models.DecimalField(default=Decimal(0), decimal_places=1, max_digits=5) @lazy_classattribute def _wagtail_root(cls): return models.Page.objects.get(path='0001') @lazy def specific(self): return self.page.specific def get_parent(self): """ Return parent resource handler. """ raise NotImplementedError('must be implemented in subclasses') def get_children(self): """ Return a queryset with all children resource handlers. """ raise NotImplementedError('must be implemented in subclasses') def set_diff(self, points=0, stars=0, propagate=True, commit=True, optimistic=False): """ Change the given resources by the given amounts and propagate to all the parents. """ # Update fields kwargs = {} if points and (points > 0 or not optimistic): self.points += points kwargs['points'] = points if stars and (stars > 0 or not optimistic): self.stars += stars kwargs['stars'] = stars if kwargs and commit: self.save(update_fields=kwargs.keys()) # Propagate to all parent resources if propagate and kwargs and commit: parent = self.get_parent() kwargs['commit'] = True kwargs['propagate'] = True if parent is not None: parent.set_diff(optimistic=False, **kwargs) def set_values(self, points=0, stars=0, propagate=True, optimistic=False, commit=True): """ Register a new value for the resource. If new value is greater than the current value, update the resource and propagate. Args: points, score, stars, (number): New value assigned to each specified resource. propagate (bool): If True (default), increment all parent nodes. optimistic (bool): If True, only update if give value is greater than the registered value. commit (bool): If True (default), commit results to the database. """ d_points = points - self.points d_stars = Decimal(stars) - self.stars self.set_diff(points=d_points, stars=d_stars, propagate=propagate, commit=commit, optimistic=optimistic)
class Activity(models.RoutablePageExt, 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') permissions = [ ('interact', 'Interact'), ('view_submissions', 'View submissions'), ] 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.' ), ) visible = models.BooleanField( _('Invisible'), default=bool, help_text=_( 'Makes activity invisible to users.' ), ) closed = models.BooleanField( _('Closed to submissions'), default=bool, help_text=_( 'A closed activity does not accept new submissions, but users can ' 'see that they still exist.' ) ) group_submission = models.BooleanField( _('Group submissions'), default=bool, help_text=_( 'If enabled, submissions are registered to groups instead of ' 'individual students.' ) ) max_group_size = models.IntegerField( _('Maximum group size'), default=6, help_text=_( 'If group submission is enabled, define the maximum size of a ' 'group.' ), ) disabled = models.BooleanField( _('Disabled'), default=bool, help_text=_( 'Activities can be automatically disabled when Codeshool ' 'encounters an error. This usually produces a message saved on ' 'the .disabled_message attribute.' ) ) disabled_message = models.TextField( _('Disabled message'), blank=True, help_text=_( 'Messsage explaining why the activity was disabled.' ) ) has_submissions = models.BooleanField(default=bool) has_correct_submissions = models.BooleanField(default=bool) section_title = property(lambda self: _(self._meta.verbose_name)) objects = ActivityManager() rules = Rules() # These properties dynamically define the progress/submission/feedback # classes associated with the current class. progress_class = AuxiliaryClassIntrospection('progress') submission_class = AuxiliaryClassIntrospection('submission') feedback_class = AuxiliaryClassIntrospection('feedback') @property def submissions(self): return self.submission_class.objects.filter( progress__activity_page_id=self.id ) def clean(self): super().clean() if not self.author_name and self.owner: name = self.owner.get_full_name() email = self.owner.email self.author_name = '%s <%s>' % (name, email) if self.disabled: raise ValidationError(self.disabled_message) def submit(self, request, user=None, **kwargs): """ Create a new Submission object for the given question and saves it on the database. Args: request: The request object for the current submission. 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 the previous submission. If recycled, sets the submission.recycled to True. user: The user who submitted the response. If not given, uses the user in the request object. """ if hasattr(request, 'username'): raise ValueError # Test if activity is active if self.closed: raise ValueError('activity is closed to new submissions') # Fetch submission class submission_class = self.submission_class if submission_class is None: raise ImproperlyConfigured( '%s must define a submission_class attribute with the ' 'appropriate submission class.' % self.__class__.__name__ ) # Add progress information to the given submission kwargs if user is None: user = request.user logger.info('%r, submission from user %r' % (self.title, user.username)) progress = self.progress_set.for_user(user) return progress.submit(request, **kwargs)
class AttendanceSheet(models.Model): """ Controls student attendance by generating a new public pass-phrase under teacher request. Students confirm attendance by typing the secret phrase within a small interval after the teacher starts checking the attendance. """ max_attempts = models.SmallIntegerField( _('Maximum number of attempts'), default=3, help_text=_( 'How many times a student can attempt to prove attendance. A ' 'maximum is necessary to avoid a brute force attack.' ), ) expiration_minutes = models.SmallIntegerField( _('Expiration time'), default=5, help_text=_( 'Time (in minutes) before attendance session expires.' ) ) owner = models.ForeignKey(models.User) last_event = models.ForeignKey('Event', blank=True, null=True) max_string_distance = models.SmallIntegerField( _('Fuzzyness'), default=1, help_text=_( 'Maximum number of wrong characters that is considered acceptable ' 'when comparing the expected passphrase with the one given by the' 'student.' ), ) max_number_of_absence = models.IntegerField(blank=True, null=True) # Properties expiration_interval = property( lambda self: datetime.timedelta(minutes=self.expiration_minutes)) attendance_checks = property( lambda self: AttendanceCheck.objects.filter(event__sheet=self) ) def __str__(self): try: return self.attendancepage_set.first().title except models.ObjectDoesNotExist: user = self.owner.get_full_name() or self.owner.username return _('Attendance sheet (%s)' % user) def new_event(self, commit=True): """ Create a new event in attendance sheet. """ current_time = now() event = Event( passphrase=phrase(), date=current_time.date(), created=current_time, expires=current_time + self.expiration_interval, sheet=self, ) self.last_event = event if commit: event.save() self.save(update_fields=['last_event']) return event def current_passphrase(self): """ Return the current passphrase. """ return self.current_event().passphrase def current_event(self): """ Return the last event created for today. If no event is found, create a new one. """ if self.last_event and self.last_event.date == now().date(): return self.last_event else: return self.new_event() def number_of_absences(self, user): """ Return the total number of absence for user. """ return self.attendance_checks.filter(user=user, has_attended=False).count() def absence_table(self, users=None, method='fraction'): """ Return a mapping between users and their respective absence rate. Args: users: A queryset of users. method: One of 'fraction' (default), 'number', 'attendance' or 'attendance-fraction' """ try: get_value_from_absence = { 'fraction': lambda x: x / num_events, 'number': lambda x: x, 'attendance': lambda x: num_events - x, 'attendance-fraction': lambda x: (num_events - x) / num_events }[method] except KeyError: raise ValueError('invalid method: %r' % method) num_events = self.events.count() if users is None: users = models.User.objects.all() result = collections.OrderedDict() for user in users: absence = self.user_absence(user) result[user] = get_value_from_absence(absence) return result def user_attempts(self, user): """ Return the number of user attempts in the last attendance event. """ if self.last_event is None: return 0 qs = self.attendance_checks.filter(user=user, event=self.last_event) return qs.count() def minutes_left(self, raises=True): """ Return how many minutes left for expiration. """ if self.last_event: time = now() if self.last_event.expires < time: return 0.0 else: dt = self.last_event.expires - time return dt.total_seconds() / 60. if raises: raise ValueError('last event is not defined') else: return None def is_expired(self): """ Return True if last_event has already expired. """ if not self.last_event: return False return self.last_event.expires < now() def is_valid(self, passphrase): """ Check if passphrase is valid. """ if self.is_expired(): return False distance = string_distance(passphrase, self.current_passphrase()) return distance <= self.max_string_distance
class HasScorePage(models.Page): """ Mixin abstract page class for Page elements that implement the Score API. Subclasses define points_value, stars_value, and difficulty fields that define how activities contribute to Codeschool score system. """ class Meta: abstract = True DIFFICULTY_TRIVIAL = 0 DIFFICULTY_VERY_EASY = 1 DIFFICULTY_EASY = 2 DIFFICULTY_REGULAR = 3 DIFFICULTY_HARD = 4 DIFFICULTY_VERY_HARD = 5 DIFFICULTY_CHALLENGE = 6 DIFFICULTY_CHOICES = [ (DIFFICULTY_TRIVIAL, _('Trivial')), (DIFFICULTY_VERY_EASY, _('Very Easy')), (DIFFICULTY_EASY, _('Easy')), (DIFFICULTY_REGULAR, _('Regular')), (DIFFICULTY_HARD, _('Hard')), (DIFFICULTY_VERY_HARD, _('Very Hard')), (DIFFICULTY_CHALLENGE, _('Challenge!')), ] SCORE_FROM_DIFFICULTY = { DIFFICULTY_TRIVIAL: 10, DIFFICULTY_VERY_EASY: 30, DIFFICULTY_EASY: 60, DIFFICULTY_REGULAR: 100, DIFFICULTY_HARD: 150, DIFFICULTY_VERY_HARD: 250, DIFFICULTY_CHALLENGE: 500, } DEFAULT_DIFFICULTY = DIFFICULTY_REGULAR points_total = models.IntegerField( _('value'), blank=True, help_text=_( 'Points may be awarded in specific contexts (e.g., associated with ' 'a quiz or in a list of activities) and in Codeschool\'s generic ' 'ranking system.')) stars_total = models.DecimalField( _('stars'), decimal_places=1, max_digits=5, blank=True, help_text=_( 'Number of stars the activity is worth (fractional stars are ' 'accepted). Stars are optional bonus points for special ' 'accomplishments that can be used to trade "special powers" in ' 'codeschool.'), default=0.0) difficulty = models.IntegerField( blank=True, choices=DIFFICULTY_CHOICES, ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) if not kwargs: self._score_memo = self.points_total, self.stars_total def clean(self): # Fill default difficulty if self.difficulty is None: self.difficulty = self.DEFAULT_DIFFICULTY # Fill default points value from difficulty if self.points_total is None: self.points_total = self.SCORE_FROM_DIFFICULTY[self.difficulty] super().clean() def save(self, *args, **kwargs): scores = getattr(self, '_score_memo', (0, 0)) super().save(*args, **kwargs) # Update the ScoreTotals table, if necessary. if scores != (self.points_total, self.stars_total): points = self.points_total stars = self.stars_total TotalScore.update(self, points=points, stars=stars) def get_score_contributions(self): """ Return a dictionary with the score value associated with points, score, and stars. """ return { 'points': self.points_total, 'stars': self.stars_total, }