diff options
Diffstat (limited to 'yaksh/models.py')
-rw-r--r-- | yaksh/models.py | 557 |
1 files changed, 506 insertions, 51 deletions
diff --git a/yaksh/models.py b/yaksh/models.py index 87a6877..686d0e6 100644 --- a/yaksh/models.py +++ b/yaksh/models.py @@ -1,3 +1,4 @@ +# Python Imports from __future__ import unicode_literals, division from datetime import datetime, timedelta import uuid @@ -8,16 +9,9 @@ from ruamel.yaml.scalarstring import PreservedScalarString from ruamel.yaml.comments import CommentedMap from random import sample from collections import Counter, defaultdict - -from django.db import models -from django.contrib.auth.models import User, Group, Permission -from django.core.exceptions import ValidationError -from django.contrib.contenttypes.models import ContentType -from taggit.managers import TaggableManager -from django.utils import timezone -from django.core.files import File import glob - +import sys +import traceback try: from StringIO import StringIO as string_io except ImportError: @@ -31,14 +25,31 @@ import zipfile import tempfile from textwrap import dedent from ast import literal_eval -from .file_utils import extract_files, delete_files +import pandas as pd + +# Django Imports +from django.db import models +from django.contrib.auth.models import User, Group, Permission +from django.core.exceptions import ValidationError +from django.contrib.contenttypes.models import ContentType +from taggit.managers import TaggableManager +from django.utils import timezone +from django.core.files import File +from django.contrib.contenttypes.fields import ( + GenericForeignKey, GenericRelation +) +from django.contrib.contenttypes.models import ContentType from django.template import Context, Template +from django.conf import settings +from django.forms.models import model_to_dict +from django.db.models import Count + +# Local Imports from yaksh.code_server import ( submit, get_result as get_result_from_code_server ) from yaksh.settings import SERVER_POOL_PORT, SERVER_HOST_NAME -from django.conf import settings -from django.forms.models import model_to_dict +from .file_utils import extract_files, delete_files from grades.models import GradingSystem languages = ( @@ -248,6 +259,15 @@ def get_image_dir(instance, filename): )) +def is_valid_time_format(time): + try: + hh, mm, ss = time.split(":") + status = True + except ValueError: + status = False + return status + + ############################################################################### class CourseManager(models.Manager): @@ -281,11 +301,16 @@ class Lesson(models.Model): # A video file video_file = models.FileField( - upload_to=get_file_dir, default=None, + upload_to=get_file_dir, max_length=255, default=None, null=True, blank=True, help_text="Please upload video files in mp4, ogv, webm format" ) + video_path = models.CharField( + max_length=255, default=None, null=True, blank=True, + help_text="Youtube id, vimeo id, others" + ) + def __str__(self): return "{0}".format(self.name) @@ -612,7 +637,7 @@ class LearningUnit(models.Model): on_delete=models.CASCADE) quiz = models.ForeignKey(Quiz, null=True, blank=True, on_delete=models.CASCADE) - check_prerequisite = models.BooleanField(default=True) + check_prerequisite = models.BooleanField(default=False) def get_lesson_or_quiz(self): unit = None @@ -691,7 +716,7 @@ class LearningModule(models.Model): order = models.IntegerField(default=0) creator = models.ForeignKey(User, related_name="module_creator", on_delete=models.CASCADE) - check_prerequisite = models.BooleanField(default=True) + check_prerequisite = models.BooleanField(default=False) check_prerequisite_passes = models.BooleanField(default=False) html_data = models.TextField(null=True, blank=True) active = models.BooleanField(default=True) @@ -1044,6 +1069,14 @@ class Course(models.Model): def get_learning_modules(self): return self.learning_module.order_by("order") + def get_learning_module(self, quiz): + modules = self.get_learning_modules() + for module in modules: + for unit in module.get_learning_units(): + if unit.quiz == quiz: + break + return module + def get_unit_completion_status(self, module, user, unit): course_module = self.learning_module.get(id=module.id) learning_unit = course_module.learning_unit.get(id=unit.id) @@ -1069,6 +1102,25 @@ class Course(models.Model): learning_units.extend(module.get_learning_units()) return learning_units + def get_lesson_posts(self): + learning_units = self.get_learning_units() + comments = [] + for unit in learning_units: + if unit.lesson is not None: + lesson_ct = ContentType.objects.get_for_model(unit.lesson) + title = unit.lesson.name + try: + post = Post.objects.get( + target_ct=lesson_ct, + target_id=unit.lesson.id, + active=True, title=title + ) + except Post.DoesNotExist: + post = None + if post is not None: + comments.append(post) + return comments + def remove_trial_modules(self): learning_modules = self.learning_module.all() for module in learning_modules: @@ -1193,8 +1245,8 @@ class CourseStatus(models.Model): self.save() def calculate_percentage(self): - if self.is_course_complete(): - quizzes = self.course.get_quizzes() + quizzes = self.course.get_quizzes() + if self.is_course_complete() and quizzes: total_weightage = 0 sum = 0 for quiz in quizzes: @@ -1278,7 +1330,7 @@ class Profile(models.Model): super(Profile, self).save(*args, **kwargs) def __str__(self): - return '%s' % (self.user.get_full_name()) + return '%s' % (self.user.get_full_name() or self.user.username) ############################################################################### @@ -1328,6 +1380,8 @@ class Question(models.Model): # Solution for the question. solution = models.TextField(blank=True) + content = GenericRelation("TableOfContents") + tc_code_types = { "python": [ ("standardtestcase", "Standard TestCase"), @@ -1656,12 +1710,17 @@ class Answer(models.Model): # Whether skipped or not. skipped = models.BooleanField(default=False) + comment = models.TextField(null=True, blank=True) + def set_marks(self, marks): if marks > self.question.points: self.marks = self.question.points else: self.marks = marks + def set_comment(self, comments): + self.comment = comments + def __str__(self): return "Answer for question {0}".format(self.question.summary) @@ -1694,17 +1753,15 @@ class QuestionPaperManager(models.Manager): def create_trial_paper_to_test_quiz(self, trial_quiz, original_quiz_id): """Creates a trial question paper to test quiz.""" - if self.filter(quiz=trial_quiz).exists(): - trial_questionpaper = self.get(quiz=trial_quiz) - else: - trial_questionpaper, trial_questions = \ - self._create_trial_from_questionpaper(original_quiz_id) - trial_questionpaper.quiz = trial_quiz - trial_questionpaper.fixed_questions\ - .add(*trial_questions["fixed_questions"]) - trial_questionpaper.random_questions\ - .add(*trial_questions["random_questions"]) - trial_questionpaper.save() + trial_quiz.questionpaper_set.all().delete() + trial_questionpaper, trial_questions = \ + self._create_trial_from_questionpaper(original_quiz_id) + trial_questionpaper.quiz = trial_quiz + trial_questionpaper.fixed_questions\ + .add(*trial_questions["fixed_questions"]) + trial_questionpaper.random_questions\ + .add(*trial_questions["random_questions"]) + trial_questionpaper.save() return trial_questionpaper @@ -1728,7 +1785,7 @@ class QuestionPaper(models.Model): total_marks = models.FloatField(default=0.0, blank=True) # Sequence or Order of fixed questions - fixed_question_order = models.CharField(max_length=255, blank=True) + fixed_question_order = models.TextField(blank=True) # Shuffle testcase order. shuffle_testcases = models.BooleanField("Shuffle testcase for each user", @@ -1762,6 +1819,8 @@ class QuestionPaper(models.Model): for question in questions: marks += question.points for question_set in self.random_questions.all(): + question_set.marks = question_set.questions.first().points + question_set.save() marks += question_set.marks * question_set.num_questions self.total_marks = marks self.save() @@ -1777,7 +1836,7 @@ class QuestionPaper(models.Model): all_questions = questions return all_questions - def make_answerpaper(self, user, ip, attempt_num, course_id): + def make_answerpaper(self, user, ip, attempt_num, course_id, special=False): """Creates an answer paper for the user to attempt the quiz""" try: ans_paper = AnswerPaper.objects.get(user=user, @@ -1796,6 +1855,7 @@ class QuestionPaper(models.Model): ans_paper.end_time = ans_paper.start_time + \ timedelta(minutes=self.quiz.duration) ans_paper.question_paper = self + ans_paper.is_special = special ans_paper.save() questions = self._get_questions_for_answerpaper() ans_paper.questions.add(*questions) @@ -2140,6 +2200,10 @@ class AnswerPaper(models.Model): # set question order questions_order = models.TextField(blank=True, default='') + extra_time = models.FloatField('Additional time in mins', default=0.0) + + is_special = models.BooleanField(default=False) + objects = AnswerPaperManager() class Meta: @@ -2222,11 +2286,23 @@ class AnswerPaper(models.Model): questions = list(self.questions.all()) return questions + def set_extra_time(self, time=0): + now = timezone.now() + self.extra_time += time + if self.status == 'completed' and self.end_time < now: + self.extra_time = time + quiz_time = self.question_paper.quiz.duration + self.start_time = now - timezone.timedelta(minutes=quiz_time) + self.end_time = now + timezone.timedelta(minutes=time) + self.status = 'inprogress' + self.save() + def time_left(self): """Return the time remaining for the user in seconds.""" secs = self._get_total_seconds() + extra_time = self.extra_time * 60 total = self.question_paper.quiz.duration*60.0 - remain = max(total - secs, 0) + remain = max(total - (secs - extra_time), 0) return int(remain) def time_left_on_question(self, question): @@ -2244,14 +2320,20 @@ class AnswerPaper(models.Model): secs = dt.seconds + dt.days*24*3600 return secs + def _get_marks_for_question(self, question): + marks = 0.0 + answers = question.answer_set.filter(answerpaper=self) + if answers.exists(): + marks = [answer.marks for answer in answers] + max_marks = max(marks) + marks = max_marks + return marks + def _update_marks_obtained(self): """Updates the total marks earned by student for this paper.""" - marks = 0 + marks = 0.0 for question in self.questions.all(): - marks_list = [a.marks - for a in self.answers.filter(question=question)] - max_marks = max(marks_list) if marks_list else 0.0 - marks += max_marks + marks += self._get_marks_for_question(question) self.marks_obtained = marks def _update_percent(self): @@ -2288,24 +2370,30 @@ class AnswerPaper(models.Model): self.end_time = datetime self.save() + def get_answer_comment(self, question_id): + answer = self.answers.filter(question_id=question_id).last() + if answer: + return answer.comment + def get_question_answers(self): """ Return a dictionary with keys as questions and a list of the corresponding answers. """ q_a = {} - for answer in self.answers.all(): - question = answer.question - if question in q_a: - q_a[question].append({ + for question in self.questions.all(): + answers = question.answer_set.filter(answerpaper=self) + if not answers.exists(): + q_a[question] = [None, 0.0] + continue + ans_errs = [] + for answer in answers: + ans_errs.append({ 'answer': answer, 'error_list': [e for e in json.loads(answer.error)] }) - else: - q_a[question] = [{ - 'answer': answer, - 'error_list': [e for e in json.loads(answer.error)] - }] + q_a[question] = ans_errs + q_a[question].append(self._get_marks_for_question(question)) return q_a def get_latest_answer(self, question_id): @@ -2315,7 +2403,7 @@ class AnswerPaper(models.Model): return self.questions.filter(active=True) def get_questions_answered(self): - return self.questions_answered.all() + return self.questions_answered.all().distinct() def get_questions_unanswered(self): return self.questions_unanswered.all() @@ -2514,7 +2602,7 @@ class AssignmentUploadManager(models.Manager): class AssignmentUpload(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE) assignmentQuestion = models.ForeignKey(Question, on_delete=models.CASCADE) - assignmentFile = models.FileField(upload_to=get_assignment_dir) + assignmentFile = models.FileField(upload_to=get_assignment_dir, max_length=255) question_paper = models.ForeignKey(QuestionPaper, blank=True, null=True, on_delete=models.CASCADE) course = models.ForeignKey(Course, null=True, blank=True, @@ -2533,11 +2621,13 @@ class StandardTestCase(TestCase): test_case = models.TextField() weight = models.FloatField(default=1.0) test_case_args = models.TextField(blank=True) + hidden = models.BooleanField(default=False) def get_field_value(self): return {"test_case_type": "standardtestcase", "test_case": self.test_case, "weight": self.weight, + "hidden": self.hidden, "test_case_args": self.test_case_args} def __str__(self): @@ -2548,11 +2638,13 @@ class StdIOBasedTestCase(TestCase): expected_input = models.TextField(default=None, blank=True, null=True) expected_output = models.TextField(default=None) weight = models.IntegerField(default=1.0) + hidden = models.BooleanField(default=False) def get_field_value(self): return {"test_case_type": "stdiobasedtestcase", "expected_output": self.expected_output, "expected_input": self.expected_input, + "hidden": self.hidden, "weight": self.weight} def __str__(self): @@ -2598,10 +2690,11 @@ class HookTestCase(TestCase): ) weight = models.FloatField(default=1.0) + hidden = models.BooleanField(default=False) def get_field_value(self): return {"test_case_type": "hooktestcase", "hook_code": self.hook_code, - "weight": self.weight} + "hidden": self.hidden, "weight": self.weight} def __str__(self): return u'Hook Testcase | Correct: {0}'.format(self.hook_code) @@ -2682,12 +2775,20 @@ class ForumBase(models.Model): image = models.ImageField(upload_to=get_image_dir, blank=True, null=True, validators=[validate_image]) active = models.BooleanField(default=True) + anonymous = models.BooleanField(default=False) class Post(ForumBase): title = models.CharField(max_length=200) - course = models.ForeignKey(Course, - on_delete=models.CASCADE, related_name='post') + target_ct = models.ForeignKey(ContentType, + blank=True, + null=True, + related_name='target_obj', + on_delete=models.CASCADE) + target_id = models.PositiveIntegerField(null=True, + blank=True, + db_index=True) + target = GenericForeignKey('target_ct', 'target_id') def __str__(self): return self.title @@ -2706,3 +2807,357 @@ class Comment(ForumBase): def __str__(self): return 'Comment by {0}: {1}'.format(self.creator.username, self.post_field.title) + + +class TOCManager(models.Manager): + + def get_data(self, course_id, lesson_id): + contents = TableOfContents.objects.filter( + course_id=course_id, lesson_id=lesson_id, content__in=[2, 3, 4] + ) + data = {} + for toc in contents: + data[toc] = LessonQuizAnswer.objects.filter( + toc_id=toc.id).values_list( + "student_id", flat=True).distinct().count() + return data + + def get_question_stats(self, toc_id): + answers = LessonQuizAnswer.objects.get_queryset().filter( + toc_id=toc_id).order_by('id') + question = TableOfContents.objects.get(id=toc_id).content_object + if answers.exists(): + answers = answers.values( + "student__first_name", "student__last_name", "student__email", + "student_id", "student__profile__roll_number", "toc_id" + ) + df = pd.DataFrame(answers) + answers = df.drop_duplicates().to_dict(orient='records') + return question, answers + + def get_per_tc_ans(self, toc_id, question_type, is_percent=True): + answers = LessonQuizAnswer.objects.filter(toc_id=toc_id).values( + "student_id", "answer__answer" + ).order_by("id") + data = None + if answers.exists(): + df = pd.DataFrame(answers) + grp = df.groupby(["student_id"]).tail(1) + total_count = grp.count().answer__answer + data = grp.groupby(["answer__answer"]).count().to_dict().get( + "student_id") + if question_type == "mcc": + tc_ids = [] + mydata = {} + for i in data.keys(): + tc_ids.extend(literal_eval(i)) + for j in tc_ids: + if j not in mydata: + mydata[j] = 1 + else: + mydata[j] +=1 + data = mydata.copy() + if is_percent: + for key, value in data.items(): + data[key] = (value/total_count)*100 + return data, total_count + + def get_answer(self, toc_id, user_id): + submission = LessonQuizAnswer.objects.filter( + toc_id=toc_id, student_id=user_id).last() + question = submission.toc.content_object + attempted_answer = submission.answer + if question.type == "mcq": + submitted_answer = literal_eval(attempted_answer.answer) + answers = [ + tc.options + for tc in question.get_test_cases(id=submitted_answer) + ] + answer = ",".join(answers) + elif question.type == "mcc": + submitted_answer = literal_eval(attempted_answer.answer) + answers = [ + tc.options + for tc in question.get_test_cases(id__in=submitted_answer) + ] + answer = ",".join(answers) + else: + answer = attempted_answer.answer + return answer, attempted_answer.correct + + def add_contents(self, course_id, lesson_id, user, contents): + toc = [] + messages = [] + for content in contents: + name = content.get('name') or content.get('summary') + if "content_type" not in content or "time" not in content: + messages.append( + (False, + f"content_type or time key is missing in {name}") + ) + else: + content_type = content.pop('content_type') + time = content.pop('time') + if not is_valid_time_format(time): + messages.append( + (False, + f"Invalid time format in {name}. " + "Format should be 00:00:00") + ) + else: + if content_type == 1: + topic = Topic.objects.create(**content) + toc.append(TableOfContents( + course_id=course_id, lesson_id=lesson_id, time=time, + content_object=topic, content=content_type + )) + messages.append((True, f"{topic.name} added successfully")) + else: + content['user'] = user + test_cases = content.pop("testcase") + que_type = content.get('type') + if "files" in content: + content.pop("files") + if "tags" in content: + content.pop("tags") + if (que_type in ['code', 'upload']): + messages.append( + (False, f"{que_type} question is not allowed. " + f"{content.get('summary')} is not added") + ) + else: + que = Question.objects.create(**content) + for test_case in test_cases: + test_case_type = test_case.pop('test_case_type') + model_class = get_model_class(test_case_type) + model_class.objects.get_or_create( + question=que, **test_case, type=test_case_type + ) + toc.append(TableOfContents( + course_id=course_id, lesson_id=lesson_id, + time=time, content_object=que, + content=content_type + )) + messages.append( + (True, f"{que.summary} added successfully") + ) + if toc: + TableOfContents.objects.bulk_create(toc) + return messages + + +class TableOfContents(models.Model): + toc_types = ((1, "Topic"), (2, "Graded Quiz"), (3, "Exercise"), (4, "Poll")) + course = models.ForeignKey(Course, on_delete=models.CASCADE, + related_name='course') + lesson = models.ForeignKey(Lesson, on_delete=models.CASCADE, + related_name='contents') + time = models.CharField(max_length=100, default=0) + content = models.IntegerField(choices=toc_types) + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) + object_id = models.PositiveIntegerField() + content_object = GenericForeignKey() + + objects = TOCManager() + + class Meta: + verbose_name_plural = "Table Of Contents" + + def get_toc_text(self): + if self.content == 1: + content_name = self.content_object.name + else: + content_name = self.content_object.summary + return content_name + + def __str__(self): + return f"TOC for {self.lesson.name} with {self.get_content_display()}" + + +class Topic(models.Model): + name = models.CharField(max_length=255) + description = models.TextField(null=True, blank=True) + content = GenericRelation(TableOfContents) + + def __str__(self): + return f"{self.name}" + + +class LessonQuizAnswer(models.Model): + toc = models.ForeignKey(TableOfContents, on_delete=models.CASCADE) + student = models.ForeignKey(User, on_delete=models.CASCADE) + answer = models.ForeignKey(Answer, on_delete=models.CASCADE) + + def check_answer(self, user_answer): + result = {'success': False, 'error': ['Incorrect answer'], + 'weight': 0.0} + question = self.toc.content_object + if question.type == 'mcq': + expected_answer = question.get_test_case(correct=True).id + if user_answer.strip() == str(expected_answer).strip(): + result['success'] = True + result['error'] = ['Correct answer'] + + elif question.type == 'mcc': + expected_answers = [ + str(opt.id) for opt in question.get_test_cases(correct=True) + ] + if set(user_answer) == set(expected_answers): + result['success'] = True + result['error'] = ['Correct answer'] + + elif question.type == 'integer': + expected_answers = [ + int(tc.correct) for tc in question.get_test_cases() + ] + if int(user_answer) in expected_answers: + result['success'] = True + result['error'] = ['Correct answer'] + + elif question.type == 'string': + tc_status = [] + for tc in question.get_test_cases(): + if tc.string_check == "lower": + if tc.correct.lower().splitlines()\ + == user_answer.lower().splitlines(): + tc_status.append(True) + else: + if tc.correct.splitlines()\ + == user_answer.splitlines(): + tc_status.append(True) + if any(tc_status): + result['success'] = True + result['error'] = ['Correct answer'] + + elif question.type == 'float': + user_answer = float(user_answer) + tc_status = [] + for tc in question.get_test_cases(): + if abs(tc.correct - user_answer) <= tc.error_margin: + tc_status.append(True) + if any(tc_status): + result['success'] = True + result['error'] = ['Correct answer'] + + elif question.type == 'arrange': + testcase_ids = sorted( + [tc.id for tc in question.get_test_cases()] + ) + if user_answer == testcase_ids: + result['success'] = True + result['error'] = ['Correct answer'] + self.answer.error = result + ans_status = result.get("success") + self.answer.correct = ans_status + if ans_status: + self.answer.marks = self.answer.question.points + self.answer.save() + return result + + def __str__(self): + return f"Lesson answer of {self.toc} by {self.student.get_full_name()}" + + +class MicroManager(models.Model): + manager = models.ForeignKey(User, on_delete=models.CASCADE, + related_name='micromanaging', null=True) + student = models.ForeignKey(User, on_delete=models.CASCADE, + related_name='micromanaged') + course = models.ForeignKey(Course, on_delete=models.CASCADE) + quiz = models.ForeignKey(Quiz, on_delete=models.CASCADE, null=True) + special_attempt = models.BooleanField(default=False) + attempts_permitted = models.IntegerField(default=0) + permitted_time = models.DateTimeField(default=timezone.now) + attempts_utilised = models.IntegerField(default=0) + wait_time = models.IntegerField('Days to wait before special attempt', + default=0) + attempt_valid_for = models.IntegerField('Validity days', default=90) + + class Meta: + unique_together = ('student', 'course', 'quiz') + + def set_wait_time(self, days=0): + self.wait_time = days + self.save() + + def increment_attempts_permitted(self): + self.attempts_permitted += 1 + self.save() + + def update_permitted_time(self, permit_time=None): + time_now = timezone.now() + self.permitted_time = time_now if not permit_time else permit_time + self.save() + + def has_student_attempts_exhausted(self): + if self.quiz.attempts_allowed == -1: + return False + question_paper = self.quiz.questionpaper_set.first() + attempts = AnswerPaper.objects.get_total_attempt( + question_paper, self.student, course_id=self.course.id + ) + last_attempt = AnswerPaper.objects.get_user_last_attempt( + question_paper, self.student, self.course.id + ) + if last_attempt: + if last_attempt.is_attempt_inprogress(): + return False + return attempts >= self.quiz.attempts_allowed + + def is_last_attempt_inprogress(self): + question_paper = self.quiz.questionpaper_set.first() + last_attempt = AnswerPaper.objects.get_user_last_attempt( + question_paper, self.student, self.course.id + ) + if last_attempt: + return last_attempt.is_attempt_inprogress() + return False + + def has_quiz_time_exhausted(self): + return not self.quiz.active or self.quiz.is_expired() + + def is_course_exhausted(self): + return not self.course.active or not self.course.is_active_enrollment() + + def is_special_attempt_required(self): + return (self.has_student_attempts_exhausted() or + self.has_quiz_time_exhausted() or self.is_course_exhausted()) + + def allow_special_attempt(self, wait_time=0): + if (self.is_special_attempt_required() and + not self.is_last_attempt_inprogress()): + self.special_attempt = True + if self.attempts_utilised >= self.attempts_permitted: + self.increment_attempts_permitted() + self.update_permitted_time() + self.set_wait_time(days=wait_time) + self.save() + + def has_special_attempt(self): + return (self.special_attempt and + (self.attempts_utilised < self.attempts_permitted)) + + def is_attempt_time_valid(self): + permit_time = self.permitted_time + wait_time = permit_time + timezone.timedelta(days=self.wait_time) + valid_time = permit_time + timezone.timedelta( + days=self.attempt_valid_for) + return wait_time <= timezone.now() <= valid_time + + def can_student_attempt(self): + return self.has_special_attempt() and self.is_attempt_time_valid() + + def get_attempt_number(self): + return self.quiz.attempts_allowed + self.attempts_utilised + 1 + + def increment_attempts_utilised(self): + self.attempts_utilised += 1 + self.save() + + def revoke_special_attempt(self): + self.special_attempt = False + self.save() + + def __str__(self): + return 'MicroManager for {0} - {1}'.format(self.student.username, + self.course.name) |