From 4cc8100a48ae7a31c852544ab4c12fb651449fb2 Mon Sep 17 00:00:00 2001 From: Ross Date: Wed, 28 Jul 2021 19:08:40 +0100 Subject: [PATCH] add sbas --- rad/settings.py | 1 + sbas/__init__.py | 0 sbas/admin.py | 3 + sbas/apps.py | 6 + sbas/migrations/0001_initial.py | 77 +++++++ sbas/migrations/__init__.py | 0 sbas/models.py | 184 +++++++++++++++ sbas/templates/sbas/exam_take.html | 17 ++ sbas/templates/sbas/question_detail.html | 27 +++ sbas/tests.py | 3 + sbas/urls.py | 30 +++ sbas/views.py | 280 +++++++++++++++++++++++ 12 files changed, 628 insertions(+) create mode 100644 sbas/__init__.py create mode 100644 sbas/admin.py create mode 100644 sbas/apps.py create mode 100644 sbas/migrations/0001_initial.py create mode 100644 sbas/migrations/__init__.py create mode 100644 sbas/models.py create mode 100755 sbas/templates/sbas/exam_take.html create mode 100755 sbas/templates/sbas/question_detail.html create mode 100644 sbas/tests.py create mode 100644 sbas/urls.py create mode 100644 sbas/views.py diff --git a/rad/settings.py b/rad/settings.py index cb786ff2..fb14fa73 100644 --- a/rad/settings.py +++ b/rad/settings.py @@ -35,6 +35,7 @@ INSTALLED_APPS = [ 'physics', 'rapids', 'longs', + 'sbas', 'wally', "reversion", "django_tables2", diff --git a/sbas/__init__.py b/sbas/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/sbas/admin.py b/sbas/admin.py new file mode 100644 index 00000000..8c38f3f3 --- /dev/null +++ b/sbas/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/sbas/apps.py b/sbas/apps.py new file mode 100644 index 00000000..5fd02543 --- /dev/null +++ b/sbas/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class SbaConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'sbas' diff --git a/sbas/migrations/0001_initial.py b/sbas/migrations/0001_initial.py new file mode 100644 index 00000000..814a6b65 --- /dev/null +++ b/sbas/migrations/0001_initial.py @@ -0,0 +1,77 @@ +# Generated by Django 3.2.5 on 2021-07-28 18:07 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import sortedm2m.fields + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Category', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('category', models.CharField(max_length=200)), + ], + ), + migrations.CreateModel( + name='Question', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('stem', models.TextField(help_text='Stem of the question')), + ('a_answer', models.TextField(default=True, help_text='Answer text')), + ('a_feedback', models.TextField(blank=True, help_text='Feedback for answer')), + ('b_answer', models.TextField(default=True, help_text='Answer text')), + ('b_feedback', models.TextField(blank=True, help_text='Feedback for answer')), + ('c_answer', models.TextField(default=True, help_text='Answer text')), + ('c_feedback', models.TextField(blank=True, help_text='Feedback for answer')), + ('d_answer', models.TextField(default=True, help_text='Answer text')), + ('d_feedback', models.TextField(blank=True, help_text='Feedback for answer')), + ('e_answer', models.TextField(default=True, help_text='Answer text')), + ('e_feedback', models.TextField(blank=True, help_text='Feedback for answer')), + ('feedback', models.TextField(blank=True, help_text='General question feedback')), + ('best_answer', models.CharField(choices=[('a', 'answer_a'), ('b', 'answer_b'), ('c', 'answer_c'), ('d', 'answer_d'), ('e', 'answer_e')], help_text='The correct answer', max_length=20)), + ('created_date', models.DateTimeField(default=django.utils.timezone.now)), + ('author', models.ManyToManyField(blank=True, help_text='Author(s) of question', related_name='sbas_authored_questions', to=settings.AUTH_USER_MODEL)), + ('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='sbas.category')), + ], + ), + migrations.CreateModel( + name='Exam', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(help_text='Name of the exam', max_length=200)), + ('active', models.BooleanField(default=False, help_text='If an exam should be available')), + ('exam_mode', models.BooleanField(default=False, help_text='If an exam should be taken in exam mode (users results will be submited to the server for marking)')), + ('publish_results', models.BooleanField(default=False, help_text='If an exams results should be available')), + ('json_creation_time', models.DateTimeField(blank=True, default=None, null=True)), + ('time_limit', models.IntegerField(blank=True, help_text='Exam time limit (in seconds)', null=True)), + ('author', models.ManyToManyField(blank=True, help_text='Author of exam', related_name='sba_exam_author', to=settings.AUTH_USER_MODEL)), + ('exam_questions', sortedm2m.fields.SortedManyToManyField(blank='true', help_text=None, related_name='exams', to='sbas.Question')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='CidUserAnswer', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('answer', models.CharField(choices=[('a', 'answer_a'), ('b', 'answer_b'), ('c', 'answer_c'), ('d', 'answer_d'), ('e', 'answer_e')], help_text='The correct answer', max_length=20)), + ('cid', models.IntegerField(blank=True, null=True)), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('exam', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='cid_user_answers', to='sbas.exam')), + ('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cid_user_answers', to='sbas.question')), + ], + ), + ] diff --git a/sbas/migrations/__init__.py b/sbas/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/sbas/models.py b/sbas/models.py new file mode 100644 index 00000000..ab26fc8a --- /dev/null +++ b/sbas/models.py @@ -0,0 +1,184 @@ +from django.db import models +from django.utils import timezone + +from django.core.files.storage import FileSystemStorage +from django.conf import settings +from django.utils.html import format_html + +from django.urls import reverse + +from django.utils.translation import ugettext_lazy as _ + +from sortedm2m.fields import SortedManyToManyField + +from generic.models import ExamBase + +import reversion + +class Category(models.Model): + category = models.CharField(max_length=200) + + def __str__(self): + return self.category + +class Question(models.Model): + stem = models.TextField( + blank=False, + help_text="Stem of the question", + ) + + answer_help_text = "Answer text" + + BEST_ANSWER_CHOICES = ( + ('a', 'answer_a'), + ('b', 'answer_b'), + ('c', 'answer_c'), + ('d', 'answer_d'), + ('e', 'answer_e'), + ) + + # I doubt this is the best way to structure the model + # (but it should work) + a_answer = models.TextField( + help_text=answer_help_text, default=True + ) + a_feedback = models.TextField( + blank=True, + help_text="Feedback for answer", + ) + + b_answer = models.TextField( + help_text=answer_help_text, default=True + ) + b_feedback = models.TextField( + blank=True, + help_text="Feedback for answer", + ) + + c_answer = models.TextField( + help_text=answer_help_text, default=True + ) + c_feedback = models.TextField( + blank=True, + help_text="Feedback for answer", + ) + + d_answer = models.TextField( + help_text=answer_help_text, default=True + ) + d_feedback = models.TextField( + blank=True, + help_text="Feedback for answer", + ) + + e_answer = models.TextField( + help_text=answer_help_text, default=True + ) + e_feedback = models.TextField( + blank=True, + help_text="Feedback for answer", + ) + + feedback = models.TextField( + blank=True, + help_text="General question feedback", + ) + + best_answer = models.CharField(max_length=20, choices=BEST_ANSWER_CHOICES, help_text="The correct answer") + + created_date = models.DateTimeField(default=timezone.now) + + author = models.ManyToManyField( + settings.AUTH_USER_MODEL, + blank=True, + help_text="Author(s) of question", + related_name="sbas_authored_questions", + ) + + category = models.ForeignKey( + Category, on_delete=models.SET_NULL, null=True, blank=True + ) + + def __str__(self): + return self.stem + + def clean(self): + if self.a_answer: + self.a_answer = self.a_answer.strip() + if self.b_answer: + self.b_answer = self.b_answer.strip() + if self.c_answer: + self.c_answer = self.c_answer.strip() + if self.d_answer: + self.d_answer = self.d_answer.strip() + if self.e_answer: + self.e_answer = self.e_answer.strip() + + def get_absolute_url(self): + return reverse("sbas:question_detail", kwargs={"pk": self.pk}) + + def get_answers(self): + return (self.a_answer, self.b_answer, self.c_answer, self.d_answer, self.e_answer) + + def get_correct_answer(self): + if self.best_answer == "a": return self.a_answer + if self.best_answer == "b": return self.b_answer + if self.best_answer == "c": return self.c_answer + if self.best_answer == "d": return self.d_answer + if self.best_answer == "e": return self.e_answer + + +@reversion.register +class Exam(ExamBase): + app_name = "sbas" + + exam_questions = SortedManyToManyField( + Question, related_name="exams", blank="true" + ) + + time_limit = models.IntegerField( + help_text="Exam time limit (in seconds)", + blank=True, + null=True, + ) + + recreate_json = None + + author = models.ManyToManyField(settings.AUTH_USER_MODEL, + blank=True, + help_text='Author of exam', + related_name="sba_exam_author") + + + +class CidUserAnswer(models.Model): + """User answers by candidate""" + + question = models.ForeignKey( + Question, related_name="cid_user_answers", on_delete=models.CASCADE + ) + answer = models.CharField(max_length=20, choices=Question.BEST_ANSWER_CHOICES, help_text="The correct answer") + + cid = models.IntegerField(blank=True, null=True) + + # Each user answer is associated with a particular exam + exam = models.ForeignKey( + Exam, related_name="cid_user_answers", on_delete=models.CASCADE, null=True + ) + + created = models.DateTimeField(auto_now_add=True) + updated = models.DateTimeField(auto_now=True) + + def __str__(self): + return "{}/{}/{}".format(self.cid, self.exam, self.question.pk) + + def get_answer(self): + return self.answer + + def get_correct(self): + return self.answer == self.question.best_answer + + def get_score(self): + if self.get_correct: + return 1 + return 0 \ No newline at end of file diff --git a/sbas/templates/sbas/exam_take.html b/sbas/templates/sbas/exam_take.html new file mode 100755 index 00000000..45630ba3 --- /dev/null +++ b/sbas/templates/sbas/exam_take.html @@ -0,0 +1,17 @@ +{% extends 'base.html' %} + +{% block content %} + +

Question [{{pos}}/{{exam_length}}]

+ +
+ +{% if previous > -1 %} +Previous question +{% endif %} +{% if next %} +Next question +{% endif %} +
+ +{% endblock %} \ No newline at end of file diff --git a/sbas/templates/sbas/question_detail.html b/sbas/templates/sbas/question_detail.html new file mode 100755 index 00000000..6a5d3e4e --- /dev/null +++ b/sbas/templates/sbas/question_detail.html @@ -0,0 +1,27 @@ +{% extends 'rapids/base.html' %} + +{% block content %} + + +{% if exam %} +
+ +{% if previous > -1 %} +Previous question +{% endif %} + This question is part of exam: {{exam.name}} [{{pos}}/{{exam_length}}] +{% if next %} +Next question +{% endif %} +
+{% endif %} + +Edit +Clone +Delete + Add Note +{% if request.user.is_superuser %} +Admin Edit +{% endif %} +{% include 'rapids/question_display_block.html' %} +{% endblock %} \ No newline at end of file diff --git a/sbas/tests.py b/sbas/tests.py new file mode 100644 index 00000000..7ce503c2 --- /dev/null +++ b/sbas/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/sbas/urls.py b/sbas/urls.py new file mode 100644 index 00000000..0dd2c19b --- /dev/null +++ b/sbas/urls.py @@ -0,0 +1,30 @@ +from django.urls import path, include +from . import views + +app_name = "sbas" + +urlpatterns = [ + # path('', views.question_list, name='question_list'), + path("", views.SbasExamViews.index, name="index"), + #path("question/", views.QuestionView.as_view(), name="question_view"), + path("question//", views.question_detail, name="question_detail"), + #path("question/create/", views.QuestionCreate.as_view(), name="anatomy_question_create"), + #path("question//update", views.QuestionUpdate.as_view(), name="anatomy_question_update"), + #path("exam///mark", views.mark, name="mark"), + #path("exam//mark", views.mark_overview, name="mark_overview"), + path("exam////take", views.exam_take, name="exam_take"), + path("exam//start", views.exam_start, name="exam_start"), + path("exam//", views.SbasExamViews.exam_overview, name="exam_overview"), + path("exam//scores", views.exam_scores_cid, + name="exam_scores_cid"), + path("exam//scores//", views.exam_scores_cid_user, + name="exam_scores_cid_user"), + path("exam//toggle_active", views.SbasExamViews.exam_toggle_active, name="exam_toggle_active"), + path("exam//toggle_results_published", views.SbasExamViews.exam_toggle_results_published, name="exam_toggle_results_published"), + #path("exam/submit", views.PhysicsExamViews.postExamAnswers, name="exam_answers_submit"), + path("exam/", views.SbasExamViews.exam_list, name="exam_list"), + path("exam/available", views.active_exams, name="active_exams"), + #path("exam/json/", views.exam_json, name="exam_json"), + #path("exam/json//recreate", views.exam_json_recreate, name="exam_json_recreate"), +] + diff --git a/sbas/views.py b/sbas/views.py new file mode 100644 index 00000000..05a1f093 --- /dev/null +++ b/sbas/views.py @@ -0,0 +1,280 @@ +from django.shortcuts import render, get_object_or_404, redirect +from django.views.decorators.csrf import csrf_exempt +from django import forms + +# from django.contrib.auth.models import User +from django.contrib.auth.decorators import login_required, user_passes_test +from django.contrib.auth.models import User + +from django.contrib.auth.mixins import LoginRequiredMixin + +from django.views.generic.edit import CreateView, UpdateView, DeleteView +from django.views.generic import ListView + +from django.db.models.functions import Lower + +from django.core.cache import cache + +from django.urls import reverse_lazy, reverse + +from django.http import Http404, JsonResponse + +from django.http import HttpResponseRedirect, HttpResponse + +from .models import Question, Category, Exam, CidUserAnswer + +from collections import defaultdict +import os +import base64 +import mimetypes +import json +import statistics +import plotly.express as px + +from generic.views import ExamViews + +from rest_framework import viewsets, permissions +from rest_framework.pagination import PageNumberPagination + +from django.core.exceptions import PermissionDenied + +class AuthorOrCheckerRequiredMixin(object): + def get_object(self, *args, **kwargs): + obj = super().get_object(*args, **kwargs) + if self.request.user.groups.filter(name="physics_checker").exists(): + return obj + if self.request.user not in obj.author.all(): + raise PermissionDenied() # or Http404 + return obj + +def question_list(request): + questions = Question.objects.all() + return render(request, "physics/question_list.html", {"questions": questions}) + + +@login_required +def question_detail(request, pk): + question = get_object_or_404(Question, pk=pk) + return render(request, "physics/question_detail.html", {"question": question}) + + +def active_exams(request): + exams = Exam.objects.all() + print(exams) + + active_exams = [] + + for exam in exams: + if exam.active: + active_exams.append(exam) + return render(request, "physics/available_exam_list.html", {"exams": active_exams}) + + +@login_required +def exam_scores_cid(request, pk): + exam = get_object_or_404(Exam, pk=pk) + + questions = exam.exam_questions.all() + + cids = ( + CidUserAnswer.objects.filter(question__in=questions) + .order_by("cid") + .values_list("cid", flat=True) + .distinct() + ) + + user_answers_and_marks = defaultdict(list) + user_answers_marks = defaultdict(list) + user_answers = defaultdict(list) + user_names = {} + + by_question = defaultdict(list) + unmarked = set() + + # Loop through all candidates + for cid in cids: + # Convoluted (probably...) + user_names[cid] = cid + for q in questions: + # Get user answer + s = q.cid_user_answers.filter(cid=cid).first() + if not s: + # skip if no answer + user_answers_marks[cid].append(0) + user_answers[cid].append("") + by_question[q].append(("", 0)) + continue + + ans = s.get_answers() + answer_scores = s.get_answer_scores() + + user_answers[cid].append(ans) + user_answers_marks[cid].append(answer_scores) + user_answers_and_marks[cid].append((ans, answer_scores)) + + zipped_ans_scores = zip(ans, answer_scores) + + by_question[q].append(zipped_ans_scores) + + user_scores = {} + for user in user_answers_marks: + user_scores[user] = sum([sum(i) for i in user_answers_marks[user]]) + + user_scores_list = list(user_scores.values()) + + if len(user_scores_list) < 1: + mean = 0 + median = 0 + mode = 0 + fig_html = "" + else: + mean = statistics.mean(user_scores_list) + median = statistics.median(user_scores_list) + try: + mode = statistics.mode(user_scores_list) + except statistics.StatisticsError: + mode = "No unique mode" + + df = user_scores_list + fig = px.histogram( + df, + x=0, + title="{}: distribution of scores".format(exam), + labels={"0": "Score"}, + height=400, + width=600, + ) + fig_html = fig.to_html() + + max_score = len(questions) + + return render( + request, + "physics/exam_scores.html", + { + "cids": cids, + "exam": exam, + "unmarked": unmarked, + "questions": questions, + "by_question": by_question, + "user_answers": dict(user_answers), + "user_answers_marks": dict(user_answers_marks), + "user_scores": user_scores, + "user_scores_list": user_scores_list, + "user_names": user_names, + "user_answers_and_marks": user_answers_and_marks, + "max_score": max_score, + "mean": mean, + "median": median, + "mode": mode, + "plot": fig_html, + }, + ) + + +def exam_scores_cid_user(request, pk, sk): + exam = get_object_or_404(Exam, pk=pk) + + # TODO:Need some kind of test for cid + cid = sk + + questions = exam.exam_questions.all() + + answers_and_marks = [] + answers_marks = [] + answers = [] + + for q in questions: + # Get user answer + user_answer = q.cid_user_answers.filter(cid=cid).first() + + ans = user_answer.get_answer() + + answer_score = user_answer.get_score() + + correct_answer = q.get_correct_answer() + + if not exam.publish_results: + correct_answer = "*****" + answer_score = 0 + answers.append(ans) + answers_marks.append(answer_score) + + merged_ans = (ans, answer_score, correct_answer) + answers_and_marks.append((q, merged_ans)) + + total_score = sum(sum(i) for i in answers_marks) + + max_score = len(questions) + + return render( + request, + "sbas/exam_scores_user.html", + { + "exam": exam, + "cid": cid, + "questions": questions, + "answers": answers, + "answers_marks": answers_marks, + "total_score": total_score, + "max_score": max_score, + "answers_and_marks": answers_and_marks, + }, + ) + + +def exam_start(request, pk): + exam = get_object_or_404(Exam, pk=pk) + + if not exam.active: + raise Http404("Exam not found") + + return render( + request, + "sbas/exam_start.html", + { + "exam": exam, + }, + ) + + +def exam_take(request, pk, sk, cid): + + exam = get_object_or_404(Exam, pk=pk) + + if not exam.active: + raise Http404("Exam not found") + + question = exam.exam_questions.all()[sk] + + exam_length = len(exam.exam_questions.all()) + + pos = exam.get_question_index(question) + 1 + + previous = -1 + if sk > 0: + previous = sk - 1 + next = sk + 1 + if sk == exam_length - 1: + next = False + + + return render( + request, + "sbas/exam_take.html", + { + "cid": cid, + "exam": exam, + "questions": question, + "next": next, + "previous": previous, + "exam_length": exam_length, + "pos": pos, + }, + ) + + +def loadJsonAnswer(answer): + pass + +SbasExamViews = ExamViews(Exam, Question, "sbas", "sbas", loadJsonAnswer) \ No newline at end of file