This commit is contained in:
Ross
2021-07-28 19:08:40 +01:00
parent 057811ec4e
commit 4cc8100a48
12 changed files with 628 additions and 0 deletions
+1
View File
@@ -35,6 +35,7 @@ INSTALLED_APPS = [
'physics',
'rapids',
'longs',
'sbas',
'wally',
"reversion",
"django_tables2",
View File
+3
View File
@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class SbaConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'sbas'
+77
View File
@@ -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')),
],
),
]
View File
+184
View File
@@ -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
+17
View File
@@ -0,0 +1,17 @@
{% extends 'base.html' %}
{% block content %}
<h2>Question [{{pos}}/{{exam_length}}]</h2>
<div>
{% if previous > -1 %}
<a href="{% url 'sbas:exam_question_detail' exam.id previous cid %}">Previous question</a>
{% endif %}
{% if next %}
<a href="{% url 'sbas:exam_question_detail' exam.id next cid %}">Next question</a>
{% endif %}
</div>
{% endblock %}
+27
View File
@@ -0,0 +1,27 @@
{% extends 'rapids/base.html' %}
{% block content %}
{% if exam %}
<div>
{% if previous > -1 %}
<a href="{% url 'rapids:exam_question_detail' exam.id previous %}">Previous question</a>
{% endif %}
This question is part of exam: <a href="{% url 'rapids:exam_overview' exam.id %}">{{exam.name}}</a> [{{pos}}/{{exam_length}}]
{% if next %}
<a href="{% url 'rapids:exam_question_detail' exam.id next %}">Next question</a>
{% endif %}
</div>
{% endif %}
<a href="{% url 'rapids:rapid_update' pk=question.pk %}" title="Edit the Rapid">Edit</a>
<a href="{% url 'rapids:rapid_clone' pk=question.pk %}" title="Clone the Rapid (duplicate everything but the images)">Clone</a>
<a href="{% url 'rapids:question_delete' pk=question.pk %}" title="Delete the Rapid">Delete</a>
<a href="{% url 'rapids:rapid_add_note' pk=question.pk %}"> Add Note</a>
{% if request.user.is_superuser %}
<a href="{% url 'admin:rapids_rapid_change' question.id %}" title="Edit the Rapid using the admin interface">Admin Edit</a>
{% endif %}
{% include 'rapids/question_display_block.html' %}
{% endblock %}
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+30
View File
@@ -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/<int:pk>/", views.question_detail, name="question_detail"),
#path("question/create/", views.QuestionCreate.as_view(), name="anatomy_question_create"),
#path("question/<int:pk>/update", views.QuestionUpdate.as_view(), name="anatomy_question_update"),
#path("exam/<int:pk>/<int:sk>/mark", views.mark, name="mark"),
#path("exam/<int:pk>/mark", views.mark_overview, name="mark_overview"),
path("exam/<int:pk>/<int:sk>/<int:cid>/take", views.exam_take, name="exam_take"),
path("exam/<int:pk>/start", views.exam_start, name="exam_start"),
path("exam/<int:pk>/", views.SbasExamViews.exam_overview, name="exam_overview"),
path("exam/<int:pk>/scores", views.exam_scores_cid,
name="exam_scores_cid"),
path("exam/<int:pk>/scores/<int:sk>/", views.exam_scores_cid_user,
name="exam_scores_cid_user"),
path("exam/<int:pk>/toggle_active", views.SbasExamViews.exam_toggle_active, name="exam_toggle_active"),
path("exam/<int:pk>/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/<int:pk>", views.exam_json, name="exam_json"),
#path("exam/json/<int:pk>/recreate", views.exam_json_recreate, name="exam_json_recreate"),
]
+280
View File
@@ -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)