diff options
-rw-r--r-- | CHANGELOG.txt | 7 | ||||
-rw-r--r-- | online_test/__init__.py | 2 | ||||
-rw-r--r-- | online_test/urls.py | 1 | ||||
-rw-r--r-- | requirements/requirements-common.txt | 1 | ||||
-rw-r--r-- | upload/models.py | 0 | ||||
-rw-r--r-- | upload/urls.py | 9 | ||||
-rw-r--r-- | upload/utils.py | 551 | ||||
-rw-r--r-- | upload/views.py | 62 | ||||
-rw-r--r-- | yaksh/migrations/0028_auto_20210112_1039.py | 25 | ||||
-rw-r--r-- | yaksh/models.py | 105 | ||||
-rw-r--r-- | yaksh/templates/yaksh/course_detail.html | 2 | ||||
-rw-r--r-- | yaksh/templates/yaksh/course_detail_options.html | 5 | ||||
-rw-r--r-- | yaksh/templates/yaksh/statistics_question.html | 23 | ||||
-rw-r--r-- | yaksh/templates/yaksh/upload_download_course_md.html | 13 | ||||
-rw-r--r-- | yaksh/templatetags/custom_filters.py | 7 | ||||
-rw-r--r-- | yaksh/test_views.py | 19 | ||||
-rw-r--r-- | yaksh/urls.py | 2 | ||||
-rw-r--r-- | yaksh/views.py | 44 |
18 files changed, 847 insertions, 31 deletions
diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 56b058b..f6ef277 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,10 @@ +=== 0.30.0 (12-01-2021) === + +* Add new UI skin. +* Add feature to count number of times MCQ option has been selected. +* Fix bug to delete and recreate fresh question papers when using god mode or user mode. +* Add feature to show stats for number of times MCQ option has been selected. + === 0.29.3 (10-12-2020) === * Show the graphs for the lesson video statistics page. diff --git a/online_test/__init__.py b/online_test/__init__.py index 267ddf3..09a303b 100644 --- a/online_test/__init__.py +++ b/online_test/__init__.py @@ -4,4 +4,4 @@ from online_test.celery_settings import app as celery_app __all__ = ('celery_app',) -__version__ = '0.29.3' +__version__ = '0.30.0' diff --git a/online_test/urls.py b/online_test/urls.py index 2a53d97..96b8bf1 100644 --- a/online_test/urls.py +++ b/online_test/urls.py @@ -18,6 +18,7 @@ urlpatterns = [ url(r'^grades/', include(('grades.urls', 'grades'))), url(r'^api/', include('api.urls', namespace='api')), url(r'^stats/', include('stats.urls', namespace='stats')), + url(r'^flatfiles/', include(('upload.urls', 'upload'))), ] urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/requirements/requirements-common.txt b/requirements/requirements-common.txt index 4475957..534e97b 100644 --- a/requirements/requirements-common.txt +++ b/requirements/requirements-common.txt @@ -8,6 +8,7 @@ social-auth-app-django==3.1.0 selenium==2.53.6 coverage ruamel.yaml==0.16.10 +pyyaml==5.3.1 markdown==2.6.9 pygments==2.2.0 celery==4.4.2 diff --git a/upload/models.py b/upload/models.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/upload/models.py diff --git a/upload/urls.py b/upload/urls.py new file mode 100644 index 0000000..207b9ea --- /dev/null +++ b/upload/urls.py @@ -0,0 +1,9 @@ +from django.conf.urls import url +from upload import views + +app_name = 'upload' + +urlpatterns = [ + url(r'^download_course_md/(?P<course_id>\d+)/$', + views.download_course_md, name="download_course_md"), +]
\ No newline at end of file diff --git a/upload/utils.py b/upload/utils.py new file mode 100644 index 0000000..cae9f1f --- /dev/null +++ b/upload/utils.py @@ -0,0 +1,551 @@ +import io +import re +import os +import json +import yaml +import requests +import more_itertools +import ruamel.yaml + +from django.core.exceptions import ObjectDoesNotExist + +from yaksh.models import Lesson, Course, LearningUnit, LearningModule, Quiz, TableOfContents + +_HEADER_RE = re.compile(r"^---\s*$") +_BLANK_RE = re.compile(r"^\s*$") +_JUPYTER_RE = re.compile(r"^meta\s*:\s*$") +_LEFTSPACE_RE = re.compile(r"^\s") +DATA_SEP = '~#~#~#' + + +def recursive_update(target, update): + """ Update recursively a (nested) dictionary with the content of another. + Inspired from https://stackoverflow.com/questions/3232943/update-value-of-a-nested-dictionary-of-varying-depth + """ + for key in update: + value = update[key] + if value is None: + del target[key] + elif isinstance(value, dict): + target[key] = recursive_update(target.get(key, {}), value) + else: + target[key] = value + return target + +def _metadata_to_dict(lines): + metadata= {} + ended = False + injupyter = False + # jupyter = [] + meta_lines = [] + + for i, line in enumerate(lines): + if i == 0 and _HEADER_RE.match(line): + continue + + if (i > 0) and _HEADER_RE.match(line): + ended = True + + if _JUPYTER_RE.match(line): + injupyter = True + elif line and not _LEFTSPACE_RE.match(line): + injupyter = False + + if injupyter: + meta_lines.append(line) + + if ended: + if meta_lines: + recursive_update(metadata, yaml.safe_load("\n".join(meta_lines))) + return metadata + +def _display_content_to_dict(lines): + ended = False + display_content = [] + + for i, line in enumerate(lines): + if (i > 0) and _HEADER_RE.match(line): + ended = True + continue + + if ended: + display_content.append(line) + + return ''.join(display_content) + + +class LessonData(): + type = "lesson" + display_content_field = 'description' + def __init__(self, obj, order, course_id): + toc_file = TableOfContents.objects.get_all_tocs_as_yaml( + course_id, obj.id, '{0}_lesson_toc.yaml'.format(obj.id) + ) + self.data = { + "id": obj.id, + "name": obj.name, + "active": obj.active, + "order": order, + } + if toc_file: + self.data.update( + {"toc": toc_file,} + ) + self.display_content = obj.description if obj.description else None + + +class QuizData(): + type = "quiz" + display_content_field = 'instructions' + def __init__(self, obj, order): + self.data = { + "id": obj.id, + "description": obj.description, + "active": obj.active, + "start_date_time": obj.start_date_time, + "end_date_time": obj.end_date_time, + "duration": obj.duration, + "pass_criteria": obj.pass_criteria, + "attempts_allowed": obj.attempts_allowed, + "allow_skip": obj.allow_skip, + "view_answerpaper": obj.view_answerpaper, + "order": order, + } + self.display_content = obj.instructions if obj.instructions else None + + +class UnitData(): + + @classmethod + def get_lesson_or_quiz_data(cls, units, course_id): + return [ + LessonData(unit_obj.lesson, unit_obj.order, course_id) + if unit_obj.lesson else QuizData(unit_obj.quiz, unit_obj.order) + for unit_obj in units + ] + + @classmethod + def md_to_dict(cls, lines): + all_unit_data = list( + more_itertools.split_at( + lines, + lambda x: x.strip('\n') == DATA_SEP + ) + ) + learning_units = [] + module_data = None + for unit_lines in all_unit_data: + if unit_lines: + metadata = _metadata_to_dict(unit_lines) + display_content = _display_content_to_dict(unit_lines) + clean_display_content = display_content.strip('\n') + try: + unit_data = metadata.get('meta', {}).get('data', {}) + order = unit_data.pop('order', 0) + unit_type = metadata.get('meta', {}).get('type', None) + unit_cls = LessonData if unit_type == 'lesson' else QuizData + except KeyError: + print("[ERROR] Data not found. Please check the MD file") + + unit_data.update( + {getattr(unit_cls,'display_content_field'): clean_display_content,} + ) + if unit_type == 'lesson': + learning_unit_data = { + "order": order, + "type": unit_type, + "quiz": None, + "lesson": unit_data, + } + learning_units.append(learning_unit_data) + elif unit_type == 'quiz': + learning_unit_data = { + "order": order, + "type": unit_type, + "quiz": unit_data, + "lesson": None, + } + learning_units.append(learning_unit_data) + elif unit_type == 'module': + module_data = ModuleData.md_to_dict(unit_lines) + + return {'module': module_data, 'learning_units': learning_units} + + @classmethod + def md_to_dict_from_file(cls, file): + with open(file, 'r') as f: + lines = f.readlines() + return cls.md_to_dict(lines) + + +class ModuleData(): + type = "module" + display_content_field = 'description' + def __init__(self, obj, course_id): + self.data = { + "id": obj.id, + "name": obj.name, + "order": obj.order, + "active":obj.active, + } + self.display_content = obj.description if obj.description else None + self.units = self.set_lessons(obj.get_learning_units(), course_id) + + def set_lessons(self, units, course_id): + return UnitData.get_lesson_or_quiz_data(units, course_id) + + + @classmethod + def md_to_dict(cls, lines): + metadata = _metadata_to_dict(lines) + display_content = _display_content_to_dict(lines) + clean_display_content = display_content.strip('\n') + try: + data = metadata.get('meta', {}).get('data', {}) + except KeyError: + print("[ERROR] Data not found. Please check the MD file") + + data.update({cls.display_content_field: clean_display_content}) + return data + + @classmethod + def md_to_dict_from_file(cls, file): + with open(file, 'r') as f: + lines = f.readlines() + return cls.md_to_dict(lines) + + +class CourseData(): + type = "course" + display_content_field = "instructions" + def __init__(self, obj): + self.data = { + "id": obj.id, + "name": obj.name, + "enrollment": obj.enrollment, + "active": obj.active, + "start_enroll_time": obj.start_enroll_time, + "end_enroll_time": obj.end_enroll_time, + } + self.display_content = obj.instructions if obj.instructions else None + self.modules = self.set_modules(obj.get_learning_modules(), obj.id) + + def set_modules(self, modules, course_id): + return [ModuleData(module_obj, course_id) for module_obj in modules] + + @classmethod + def md_to_dict(cls, file): + with open(file, 'r') as f: + lines = f.readlines() + metadata = _metadata_to_dict(lines) + display_content = _display_content_to_dict(lines) + clean_display_content = display_content.strip('\n') + try: + data = metadata.get('meta', {}).get('data', {}) + except KeyError: + print("[ERROR] Data not found. Please check the MD file") + + data.update({cls.display_content_field: clean_display_content}) + return data + + +def create_header(data, dtype): + header = [] + metadata = { + "type": dtype, + "data": data + } + yaml=ruamel.yaml.YAML() + yaml.default_flow_style = False + io_obj = io.StringIO() + + yaml.dump({"meta": metadata}, io_obj) + + if metadata['data'].get('id', None): + raw_yaml_data = yaml.load(io_obj.getvalue()) + raw_yaml_data['meta']['data'].yaml_add_eol_comment('Do Not Change This Value', 'id') + io_obj = io.StringIO() + yaml.dump(raw_yaml_data, io_obj) + + header.extend(io_obj.getvalue().splitlines()) + header = ["---"] + header + ["---"] + + return header + + +def get_course_data(course_id): + course_obj = Course.objects.get(id=course_id) + return CourseData(course_obj) + + +def _create_clean_file_name(name, ext): + clean_name = re.sub(r'[^\w\s-]', '', name).strip().lower() + return re.sub(r'[-\s]+', '_', clean_name) + '.' + ext + +def _get_file_name_from_object(content_object, ext): + name = ' '.join( + [content_object.type, content_object.data.get('name')] + ) + return _create_clean_file_name(name, ext) + + + +def create_md(content_object, file_name=None, multiple_obj=False): + file_content = [] + if not file_name: + dest_file_name = _get_file_name_from_object(content_object, 'md') + else: + dest_file_name = file_name + data = content_object.data + dtype = content_object.type + seperator = '\n{0}\n'.format(DATA_SEP) if multiple_obj else '' + header = create_header(data, dtype) + if content_object.display_content: + file_content.append(content_object.display_content) + + file_content = ( + '\n'.join(header) + '\n\n' + '\n'.join(file_content) + + seperator + ) + + with open(dest_file_name, 'a+') as f: + f.write(file_content) + + return dest_file_name + + +def write_course_to_file(course_id): + # Create the course md file + course = get_course_data(course_id) + course_file = create_md(course) + + course_map = {'course': course_file, 'modules': []} + + # Create the modules and lessons md files + for module in course.modules: + mod_file = create_md(module, multiple_obj=True) + for lesson in module.units: + create_md(lesson, mod_file, multiple_obj=True) + + course_map['modules'].append( + {'file': mod_file,} + ) + + with open('toc.yml', 'w') as f: + f.write(yaml.safe_dump(course_map)) + + +def convert_md_to_dict(toc, user): + course_file = toc.get('course') + course_data = CourseData.md_to_dict(course_file) + module_obj_list = [] + for module in toc.get('modules', []): + module_file = module.get('file') + module_data = UnitData.md_to_dict_from_file( + module_file + ).get('module') + module_id = module_data.get('id', None) + if module_id: + mod_created = False + module_obj = LearningModule.objects.get(id=module_id) + module_obj.__dict__.update(module_data) + module_obj.save() + + else: + mod_created = True + module_data.update({'creator': user}) + module_obj = LearningModule.objects.create( + **module_data + ) + + unit_file = module.get('units', None) + unit_list = UnitData.md_to_dict_from_file(module_file).get('learning_units') + unit_obj_list = [] + for unit in unit_list: + unit_type = unit.get('type') + lesson_or_quiz_obj = None + if unit_type == 'lesson': + lq_data = unit.pop('lesson') + lq_data.update({'creator': user}) + if lq_data.get('id'): # Lesson already exists + lesson_or_quiz_obj = Lesson.objects.get(id=lq_data.get('id')) + lesson_or_quiz_obj.__dict__.update(lq_data) + lesson_or_quiz_obj.save() + + toc_file = lq_data.get('toc', None) + if toc_file: + with open(toc_file, 'r') as tocf: + toc_data = ruamel.yaml.safe_load_all(tocf.read()) + results = TableOfContents.objects.add_contents( + course_data.get('id'), lesson_or_quiz_obj.id , user, toc_data) + for status, msg in results: + if status == False: + raise Exception(msg) + else: + lesson_or_quiz_obj = Lesson.objects.create( + **lq_data, + ) + unit['lesson'] = lesson_or_quiz_obj + + toc_file = lq_data.get('toc', None) + if toc_file: + with open(toc_file, 'r') as tocf: + toc_data = ruamel.yaml.safe_load_all(tocf.read()) + results = TableOfContents.objects.add_contents( + course_data.get('id'), lesson_or_quiz_obj.id , user, toc_data) + for status, msg in results: + if status == False: + raise Exception(msg) + else: + lq_data = unit.pop('quiz') + lq_data.update({'creator': user}) + if lq_data.get('id'): # Quiz already exists + lesson_or_quiz_obj = Quiz.objects.get(id=lq_data.get('id')) + lesson_or_quiz_obj.__dict__.update(lq_data) + lesson_or_quiz_obj.save() + else: + lesson_or_quiz_obj = Quiz.objects.create( + **lq_data, + ) + unit['quiz'] = lesson_or_quiz_obj + + if not lq_data.get('id'): + unit_obj, unit_created = LearningUnit.objects.create( + **unit + ), True + else: + lesson_or_quiz_class_map = { + 'lesson': Lesson, + 'quiz': Quiz, + } + unit_created = False + lq_obj_id = lq_data.get('id') + lq_obj = lesson_or_quiz_class_map.get(unit_type).objects.get(id=lq_obj_id) + m_units = module_obj.learning_unit.values_list('id', flat=True) + lq_units = lq_obj.learningunit_set.values_list('id', flat=True) + unit_id = list(set(m_units) & set(lq_units))[0] + + unit_obj = LearningUnit.objects.get(id=unit_id) + unit_obj.__dict__.update({'order': unit.get('order', 0)}) + unit_obj.save() + + if unit_created: + unit_obj_list.append(unit_obj) + + module_obj.learning_unit.add(*unit_obj_list) + if mod_created: + module_obj_list.append(module_obj) + + if course_data.get('id', None): + course_obj = Course.objects.get(id=course_data.get('id')) + course_obj.__dict__.update(course_data) + course_obj.save() + else: + course_data.update({'creator': user}) + course_obj, course_created = Course.objects.create( + **course_data + ), True + + course_obj.learning_module.add(*module_obj_list) + + return course_obj + + +def check_data(toc): + course_file = toc.get('course') + course_data = CourseData.md_to_dict(course_file) + course_id = course_data.get('id') + module_id_list = [] + for data_elem in toc.get('modules', []): + _file = data_elem.get('file') + _data = UnitData.md_to_dict_from_file(_file) + + module_id = _data.get('module', None).get('id', None) + if module_id: + module_id_list.append(module_id) + + lesson_id_list = [] + quiz_id_list = [] + for unit in _data.get('learning_units'): + unit_id = unit.get('id', None) + unit_type = unit.get('type', None) + if unit_id: + if unit_type == 'lesson': + lesson_id_list.append(unit_id) + else: + quiz_id_list.append(unit_id) + + try: + if not has_relationship(module_id, 'learning_module', lesson_id_list, unit_type): + msg = "Lesson IDs used in metadata do not belong to current course, Kindly inspect and reupload" + return False, msg + if not has_relationship(module_id, 'learning_module', quiz_id_list, unit_type): + msg = "Quiz IDs used in metadata do not belong to current course, Kindly inspect and reupload" + return False, msg + except ObjectDoesNotExist: + msg = "Object does not exist in DB" + return False, msg + + try: + if not has_relationship(course_id, 'course', module_id_list): + msg = "Module IDs used in metadata do not belong to current course, Kindly inspect and reupload" + return False, msg + + if has_duplicate_id(course_id, 'course', module_id_list): + msg = "Modules metadata contains duplicate IDs, Kindly inspect and reupload" + return False, msg + except ObjectDoesNotExist: + msg = "Object does not exist in DB" + return False, msg + return True, 'File check successful' + + +def get_parent_child_data_from_db(parent_id, parent_type, child_id_list, child_type=None): + if parent_type == 'learning_module': + mod_obj = LearningModule.objects.get(id=parent_id) + relationship_id_list = [ e for e in + mod_obj.learning_unit.order_by( + "order" + ).values_list( + child_type + '__id', flat=True, + ) if e != None + ] + return relationship_id_list + + elif parent_type == 'course': + course_obj = Course.objects.get(id=parent_id) + relationship_id_list = course_obj.get_learning_modules().values_list( + 'id', flat=True, + ) + return relationship_id_list + +def has_duplicate_id(parent_id, parent_type, child_id_list, child_type=None): + relationship_id_list = get_parent_child_data_from_db( + parent_id, parent_type, child_id_list, child_type + ) + if len(child_id_list) != len(set(relationship_id_list)): + return True # duplicates exist + else: + return False # duplicates do not exist + +def has_relationship(parent_id, parent_type, child_id_list, child_type=None): + relationship_id_list = get_parent_child_data_from_db( + parent_id, parent_type, child_id_list, child_type + ) + + for _id in child_id_list: + if _id not in relationship_id_list: + return False + return True + +def read_toc(file): + with open(file, 'r') as f: + toc = yaml.load(f, Loader=yaml.FullLoader) + return toc + +def upload_course(user): + toc = read_toc('toc.yml') + status, msg = check_data(toc) + course_data = convert_md_to_dict(toc, user) + return status, msg + diff --git a/upload/views.py b/upload/views.py new file mode 100644 index 0000000..fb80c07 --- /dev/null +++ b/upload/views.py @@ -0,0 +1,62 @@ +import tempfile +import os +from zipfile import ZipFile +from io import BytesIO as string_io + +from django.http import HttpResponse +from django.shortcuts import render +from django.contrib.auth.decorators import login_required +from django.contrib import messages + +from upload.utils import upload_course, write_course_to_file + + +def upload_course_md(request): + if request.method == 'POST': + status = False + msg = None + user = request.user + course_upload_file = request.FILES.get('course_upload_md') + file_extension = os.path.splitext(course_upload_file.name)[1][1:] + if file_extension not in ['zip']: + messages.warning( + request, "Please upload zip file" + ) + else: + curr_dir = os.getcwd() + try: + with tempfile.TemporaryDirectory() as tmpdirname, ZipFile(course_upload_file, 'r') as zip_file: + zip_file.extractall(tmpdirname) + os.chdir(tmpdirname) + status, msg = upload_course(user) + except Exception as e: + import traceback + traceback.print_exc() + messages.warning(request, f"Error parsing file structure: {e}") + finally: + os.chdir(curr_dir) + + return status, msg + +def download_course_md(request, course_id): + curr_dir = os.getcwd() + zip_file_name = string_io() + try: + with tempfile.TemporaryDirectory() as tmpdirname, ZipFile(zip_file_name, 'w') as zip_file: + os.chdir(tmpdirname) + write_course_to_file(course_id) + + for foldername, subfolders, filenames in os.walk(tmpdirname): + for filename in filenames: + zip_file.write(os.path.join(filename)) + except Exception as e: + messages.warning(request, f"Error while downloading file: {e}") + finally: + os.chdir(curr_dir) + + zip_file_name.seek(0) + response = HttpResponse(content_type='application/zip') + response['Content-Disposition'] = 'attachment; filename=course.zip' + response.write(zip_file_name.read()) + + return response
\ No newline at end of file diff --git a/yaksh/migrations/0028_auto_20210112_1039.py b/yaksh/migrations/0028_auto_20210112_1039.py new file mode 100644 index 0000000..448de98 --- /dev/null +++ b/yaksh/migrations/0028_auto_20210112_1039.py @@ -0,0 +1,25 @@ +# Generated by Django 3.0.7 on 2021-01-12 05:09 + +import datetime +from django.db import migrations, models +from django.utils.timezone import utc + + +class Migration(migrations.Migration): + + dependencies = [ + ('yaksh', '0027_release_0_28_0'), + ] + + operations = [ + migrations.AlterField( + model_name='course', + name='end_enroll_time', + field=models.DateTimeField(default=datetime.datetime(2198, 12, 31, 18, 7, tzinfo=utc), null=True, verbose_name='End Date and Time for enrollment of course'), + ), + migrations.AlterField( + model_name='quiz', + name='end_date_time', + field=models.DateTimeField(default=datetime.datetime(2198, 12, 31, 18, 7, tzinfo=utc), null=True, verbose_name='End Date and Time of the quiz'), + ), + ] diff --git a/yaksh/models.py b/yaksh/models.py index a29e910..a475493 100644 --- a/yaksh/models.py +++ b/yaksh/models.py @@ -321,7 +321,6 @@ class Lesson(models.Model): lesson_files = self.get_files() new_lesson = self new_lesson.id = None - new_lesson.name = "Copy of {0}".format(self.name) new_lesson.creator = user new_lesson.save() for _file in lesson_files: @@ -600,7 +599,6 @@ class Quiz(models.Model): question_papers = self.questionpaper_set.all() new_quiz = self new_quiz.id = None - new_quiz.description = "Copy of {0}".format(self.description) new_quiz.creator = user new_quiz.save() for qp in question_papers: @@ -846,12 +844,13 @@ class LearningModule(models.Model): percent = round((count / units.count()) * 100) return percent - def _create_module_copy(self, user, module_name): + def _create_module_copy(self, user, module_name=None): learning_units = self.learning_unit.order_by("order") new_module = self new_module.id = None - new_module.name = module_name new_module.creator = user + if module_name: + new_module.name = module_name new_module.save() for unit in learning_units: new_unit = unit._create_unit_copy(user) @@ -957,8 +956,8 @@ class Course(models.Model): copy_course_name = "Copy Of {0}".format(self.name) new_course = self._create_duplicate_instance(user, copy_course_name) for module in learning_modules: - copy_module_name = "Copy of {0}".format(module.name) - new_module = module._create_module_copy(user, copy_module_name) + copy_module_name = module.name + new_module = module._create_module_copy(user) new_course.learning_module.add(new_module) return new_course @@ -1381,7 +1380,7 @@ class Question(models.Model): # Solution for the question. solution = models.TextField(blank=True) - content = GenericRelation("TableOfContents") + content = GenericRelation("TableOfContents", related_query_name='questions') tc_code_types = { "python": [ @@ -1754,7 +1753,8 @@ 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.""" - trial_quiz.questionpaper_set.all().delete() + if self.filter(quiz=trial_quiz).exists(): + self.get(quiz=trial_quiz).delete() trial_questionpaper, trial_questions = \ self._create_trial_from_questionpaper(original_quiz_id) trial_questionpaper.quiz = trial_quiz @@ -1993,6 +1993,32 @@ class AnswerPaperManager(models.Manager): questions.append(question.id) return Counter(questions) + def get_per_answer_stats(self, questionpaper_id, attempt_number, + course_id, status='completed'): + papers = self.filter(question_paper_id=questionpaper_id, + course_id=course_id, + attempt_number=attempt_number, status=status) + questions = Question.objects.filter( + questions__id__in=papers, + ).distinct() + + stats = {} + for question in questions: + answers = Answer.objects.filter( + answerpaper__id__in=papers, question=question.id + ).values('answer', 'question__id', 'answerpaper__id') + question_ans_count = {} + answerpaper_count = [] + for ans in answers: + if ans.get('answerpaper__id'): + if ans.get('answer') not in question_ans_count: + question_ans_count[ans.get('answer')] = 1 + else: + question_ans_count[ans.get('answer')] += 1 + answerpaper_count.append(ans.get('answerpaper__id')) + stats[question] = question_ans_count + return stats + def get_all_questions_answered(self, questionpaper_id, attempt_number, course_id, status='completed'): ''' Return a dict of answered question id as key and count as value''' @@ -2046,16 +2072,27 @@ class AnswerPaperManager(models.Manager): course_id) questions = self.get_all_questions(questionpaper_id, attempt_number, course_id) + per_answer_stats = self.get_per_answer_stats( + questionpaper_id, attempt_number, course_id + ) all_questions = Question.objects.filter( id__in=set(questions), active=True ).order_by('type') for question in all_questions: if question.id in questions_answered: - question_stats[question] = [questions_answered[question.id], - questions[question.id]] + question_stats[question] = { + 'answered': [questions_answered[question.id], + questions[question.id]], + 'per_answer': per_answer_stats[question], + } + else: - question_stats[question] = [0, questions[question.id]] + question_stats[question] = { + 'answered': [0, questions[question.id]], + 'per_answer': per_answer_stats[question], + } + return question_stats def _get_answerpapers_for_quiz(self, questionpaper_id, course_id, @@ -2823,6 +2860,17 @@ class TOCManager(models.Manager): "student_id", flat=True).distinct().count() return data + def get_all_tocs_as_yaml(self, course_id, lesson_id, file_path): + all_tocs = TableOfContents.objects.filter( + course_id=course_id, lesson_id=lesson_id, + ) + if not all_tocs.exists(): + return None + for toc in all_tocs: + toc.get_toc_as_yaml(file_path) + return file_path + + def get_question_stats(self, toc_id): answers = LessonQuizAnswer.objects.get_queryset().filter( toc_id=toc_id).order_by('id') @@ -2971,6 +3019,39 @@ class TableOfContents(models.Model): content_name = self.content_object.summary return content_name + def get_toc_as_yaml(self, file_path): + data = {'content_type': self.content, 'time': self.time} + if self.topics.exists(): + content = self.topics.first() + data.update( + { + 'name': content.name, + 'description': content.description, + } + ) + elif self.questions.exists(): + content = self.questions.first() + tc_data = [] + for tc in content.get_test_cases(): + _tc_as_dict = model_to_dict( + tc, exclude=['id', 'testcase_ptr', 'question'], + ) + tc_data.append(_tc_as_dict) + data.update( + { + 'summary': content.summary, + 'type': content.type, + 'language': content.language, + 'description': content.description, + 'points': content.points, + 'testcase': tc_data, + } + ) + yaml_block = dict_to_yaml(data) + with open(file_path, "a") as yaml_file: + yaml_file.write(yaml_block) + return yaml_file + def __str__(self): return f"TOC for {self.lesson.name} with {self.get_content_display()}" @@ -2978,7 +3059,7 @@ class TableOfContents(models.Model): class Topic(models.Model): name = models.CharField(max_length=255) description = models.TextField(null=True, blank=True) - content = GenericRelation(TableOfContents) + content = GenericRelation(TableOfContents, related_query_name='topics') def __str__(self): return f"{self.name}" diff --git a/yaksh/templates/yaksh/course_detail.html b/yaksh/templates/yaksh/course_detail.html index 9f75a68..8661aea 100644 --- a/yaksh/templates/yaksh/course_detail.html +++ b/yaksh/templates/yaksh/course_detail.html @@ -55,6 +55,8 @@ {% include "yaksh/addteacher.html" %} {% elif is_teachers %} {% include "yaksh/course_teachers.html" %} + {% elif is_upload_download_md %} + {% include "yaksh/upload_download_course_md.html" %} {% else %} <div class="jumbotron"> <h1 class="display-4">Manage Course</h1> diff --git a/yaksh/templates/yaksh/course_detail_options.html b/yaksh/templates/yaksh/course_detail_options.html index 84f78ce..f9393ed 100644 --- a/yaksh/templates/yaksh/course_detail_options.html +++ b/yaksh/templates/yaksh/course_detail_options.html @@ -43,4 +43,9 @@ Current Teachers/TAs </a> </li> + <li class="nav-item"> + <a class="nav-link list-group-item {% if is_upload_download_md %} active {% endif %}" href="{% url 'yaksh:upload_download_course_md' course.id %}" data-toggle="tooltip" title="Upload / Download MD files" data-placement="top"> + Upload / Download MD + </a> + </li> </ul>
\ No newline at end of file diff --git a/yaksh/templates/yaksh/statistics_question.html b/yaksh/templates/yaksh/statistics_question.html index 5983835..d70256b 100644 --- a/yaksh/templates/yaksh/statistics_question.html +++ b/yaksh/templates/yaksh/statistics_question.html @@ -1,4 +1,5 @@ {% extends "manage.html" %} +{% load custom_filters %} {% block title %} Question Statistics {% endblock %} {% block pagetitle %} Statistics for {{ quiz.description }}{% endblock pagetitle %} @@ -20,8 +21,8 @@ {% if question_stats %} <p><b>Total number of participants: {{ total }}</b></p> <table class="table table-responsive-sm"> - <tr class="bg-light yakshred"><th>Question</th><th>Type</th><th>Total</th><th>Answered Correctly</th></tr> - {% for question, value in question_stats.items %} + <tr class="bg-light yakshred"><th>Question</th><th></th><th>Type</th><th>Total</th><th>Answered Correctly</th></tr> + {% for question, data in question_stats.items %} <tr> <td style="width: 45%"> <a href="#collapse_question_{{question.id}}" data-toggle="collapse"> @@ -61,12 +62,14 @@ <p> <ol> {% for tc in question.testcase_set.all %} - <li> - {{ tc.mcqtestcase.options|safe }} - {% if tc.mcqtestcase.correct %} - <span class="badge badge-success">Correct</span> - {% endif %} - </li> + <li> + {{ tc.mcqtestcase.options }} + {% if tc.mcqtestcase.correct %} + <span class="badge badge-primary">Correct</span> + {% endif %} + {% get_dict_value data.per_answer tc.id|stringformat:"i" as num %} + <span class="badge badge-info">Answered: {{ num }}</span> + </li> {% endfor %} </ol> </p> @@ -76,7 +79,9 @@ </div> </td> <td>{{ question.type }}</td> - <td>{{value.1}}</td><td>{{ value.0 }} ({% widthratio value.0 value.1 100 %}%)</td> + <td>{{data.answered.1}}</td><td>{{ data.answered.0 }} ({% widthratio data.answered.0 data.answered.1 100 %}%)</td> + + </tr> {% endfor %} </table> diff --git a/yaksh/templates/yaksh/upload_download_course_md.html b/yaksh/templates/yaksh/upload_download_course_md.html new file mode 100644 index 0000000..072ae4c --- /dev/null +++ b/yaksh/templates/yaksh/upload_download_course_md.html @@ -0,0 +1,13 @@ +<div> + <a href="{% url 'upload:download_course_md' course.id %}"> + <i class="fa fa-download"></i> Download + </a> + <br><br> + <form action="" method="POST" enctype="multipart/form-data"> + {% csrf_token %} + <input type="file" name="course_upload_md" required=""> + <button class="btn btn-outline-success" id="course_upload_md_btn" name="course_upload_md_btn"> + <i class="fa fa-upload"></i> Upload + </button> + </form> +</div>
\ No newline at end of file diff --git a/yaksh/templatetags/custom_filters.py b/yaksh/templatetags/custom_filters.py index 7eba939..81572a7 100644 --- a/yaksh/templatetags/custom_filters.py +++ b/yaksh/templatetags/custom_filters.py @@ -211,3 +211,10 @@ def get_lesson_views(course_id, lesson_id): return TrackLesson.objects.filter( course_id=course_id, lesson_id=lesson_id, watched=True ).count(), course.students.count() + + +@register.simple_tag +def get_dict_value(dictionary, key): + return dictionary.get(key, None) + + diff --git a/yaksh/test_views.py b/yaksh/test_views.py index c2fa4ac..31066d1 100644 --- a/yaksh/test_views.py +++ b/yaksh/test_views.py @@ -2168,7 +2168,8 @@ class TestCourses(TestCase): # Teacher Login # Given # Add files to a lesson - lesson_file = SimpleUploadedFile("file1.txt", b"Test") + file_content = b"Test" + lesson_file = SimpleUploadedFile("file1.txt", file_content) django_file = File(lesson_file) lesson_file_obj = LessonFile() lesson_file_obj.lesson = self.lesson @@ -2203,18 +2204,18 @@ class TestCourses(TestCase): self.assertEqual(response.status_code, 200) self.assertEqual(courses.last().creator, self.teacher) self.assertEqual(courses.last().name, "Copy Of Python Course") - self.assertEqual(module.name, "Copy of demo module") + self.assertEqual(module.name, "demo module") self.assertEqual(module.creator, self.teacher) self.assertEqual(module.order, 0) self.assertEqual(len(units), 2) - self.assertEqual(cloned_lesson.name, "Copy of demo lesson") + self.assertEqual(cloned_lesson.name, "demo lesson") self.assertEqual(cloned_lesson.creator, self.teacher) - self.assertEqual(cloned_quiz.description, "Copy of demo quiz") + self.assertEqual(cloned_quiz.description, "demo quiz") self.assertEqual(cloned_quiz.creator, self.teacher) self.assertEqual(cloned_qp.__str__(), - "Question Paper for Copy of demo quiz") - self.assertEqual(os.path.basename(expected_lesson_files[0].file.name), - os.path.basename(actual_lesson_files[0].file.name)) + "Question Paper for demo quiz") + self.assertTrue(expected_lesson_files.exists()) + self.assertEquals(expected_lesson_files[0].file.read(), file_content) for lesson_file in self.all_files: file_path = lesson_file.file.path @@ -5655,10 +5656,12 @@ class TestShowStatistics(TestCase): "course_id": self.course.id}), follow=True ) + question_stats = response.context['question_stats'] self.assertEqual(response.status_code, 200) self.assertTemplateUsed(response, 'yaksh/statistics_question.html') + self.assertIn(self.question, list(question_stats.keys())) self.assertSequenceEqual( - response.context['question_stats'][self.question], [1, 1] + list(question_stats.values())[0]['answered'], [1, 1] ) self.assertEqual(response.context['attempts'][0], 1) self.assertEqual(response.context['total'], 1) diff --git a/yaksh/urls.py b/yaksh/urls.py index e93d80a..b7d8ff6 100644 --- a/yaksh/urls.py +++ b/yaksh/urls.py @@ -271,4 +271,6 @@ urlpatterns = [ views.download_sample_toc, name='download_sample_toc'), path('manage/upload_marks/<int:course_id>/<int:questionpaper_id>/', views.upload_marks, name='upload_marks'), + path(r'manage/upload_download_course_md/<int:course_id>', + views.upload_download_course_md, name="upload_download_course_md"), ] diff --git a/yaksh/views.py b/yaksh/views.py index 11a77b8..50f9ded 100644 --- a/yaksh/views.py +++ b/yaksh/views.py @@ -40,7 +40,7 @@ from yaksh.models import ( StdIOBasedTestCase, StringTestCase, TestCase, User, get_model_class, FIXTURES_DIR_PATH, MOD_GROUP_NAME, Lesson, LessonFile, LearningUnit, LearningModule, CourseStatus, question_types, Post, Comment, - Topic, TableOfContents, LessonQuizAnswer, MicroManager + Topic, TableOfContents, LessonQuizAnswer, MicroManager, dict_to_yaml ) from stats.models import TrackLesson from yaksh.forms import ( @@ -230,6 +230,9 @@ def results_user(request): @email_verified def add_question(request, question_id=None): user = request.user + if not is_moderator(user): + raise Http404('You are not allowed to view this page !') + test_case_type = None if question_id is not None: @@ -2621,6 +2624,23 @@ def download_sample_toc(request): @login_required @email_verified +def download_toc(request, course_id, lesson_id): + user = request.user + tmp_file_path = tempfile.mkdtemp() + yaml_path = os.path.join(tmp_file_path, "lesson_toc.yaml") + TableOfContents.objects.get_all_tocs_as_yaml(course_id, lesson_id, yaml_path) + + with open(yaml_path, 'r') as yml_file: + response = HttpResponse(yml_file.read(), content_type='text/yaml') + response['Content-Disposition'] = ( + 'attachment; filename="lesson_toc.yaml"' + ) + return response + + + +@login_required +@email_verified def duplicate_course(request, course_id): user = request.user course = Course.objects.get(id=course_id) @@ -4144,3 +4164,25 @@ def _read_marks_csv(request, reader, course, question_paper, question_ids): messages.info(request, 'Updated successfully for user: {0}, question: {1}'.format( username, question.summary)) + + +@login_required +@email_verified +def upload_download_course_md(request, course_id): + course = get_object_or_404(Course, pk=course_id) + if request.method == "POST": + from upload.views import upload_course_md + status, msg = upload_course_md(request) + if status: + messages.success(request, "MD File Successfully uploaded to course") + else: + messages.warning(request, "{0}".format(msg)) + return redirect( + 'yaksh:course_detail', course.id + ) + else: + context = { + 'course': course, + 'is_upload_download_md': True, + } + return my_render_to_response(request, 'yaksh/course_detail.html', context) |