This commit is contained in:
Ross
2021-12-20 19:16:17 +00:00
parent 9e9a059e62
commit 6214be9939
11 changed files with 219 additions and 25 deletions
+2 -1
View File
@@ -48,7 +48,7 @@ from .models import (
# HalfMarkAnswers, # HalfMarkAnswers,
# IncorrectAnswers, # IncorrectAnswers,
) )
from generic.models import Examination from generic.models import CidUser, Examination
from generic.views import ExamCloneMixin, ExamViews, GenericViewBase from generic.views import ExamCloneMixin, ExamViews, GenericViewBase
from reversion.views import RevisionMixin from reversion.views import RevisionMixin
@@ -243,6 +243,7 @@ def loadJsonAnswer(answer):
exam = get_object_or_404(Exam, pk=eid) exam = get_object_or_404(Exam, pk=eid)
if not exam.check_cid_user(answer["cid"], answer["passcode"]): if not exam.check_cid_user(answer["cid"], answer["passcode"]):
return False, JsonResponse( return False, JsonResponse(
{"success": False, "error": "invalid cid / passcode"} {"success": False, "error": "invalid cid / passcode"}
+38 -3
View File
@@ -1,6 +1,7 @@
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.db import models from django.db import models
from django.http import HttpRequest from django.http import HttpRequest
from django.utils import timezone
from smtplib import SMTPException from smtplib import SMTPException
from django.urls import reverse from django.urls import reverse
@@ -13,7 +14,7 @@ import tagulous.models
from sortedm2m.fields import SortedManyToManyField from sortedm2m.fields import SortedManyToManyField
from django.conf import settings from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.mail import send_mail from django.core.mail import send_mail
@@ -114,6 +115,8 @@ class ExamBase(models.Model):
stats_graph = models.TextField(default=0) stats_graph = models.TextField(default=0)
notes = GenericRelation("generic.CidUserExam")
# time_limit = models.IntegerField( # time_limit = models.IntegerField(
# help_text="Exam time limit (in seconds). Default is 2100 secondse (35 minutes)", # help_text="Exam time limit (in seconds). Default is 2100 secondse (35 minutes)",
# default=2100, # default=2100,
@@ -163,6 +166,38 @@ class ExamBase(models.Model):
return True return True
def get_or_create_cid_user_exam(self, cid_user, start_time=None):
content_type = ContentType.objects.get_for_model(self)
c = CidUserExam.objects.filter(
content_type=content_type, object_id=self.pk, cid_user=cid_user
).first()
print(c)
if c:
return c
if start_time is None:
start_time = timezone.now()
new = CidUserExam(
content_type=content_type,
object_id=self.pk,
cid_user=cid_user,
start_time=start_time,
)
new.save()
return new
def get_cid_user_exams(self, cid_user=None):
content_type = ContentType.objects.get_for_model(self)
if cid_user is None:
return CidUserExam.objects.filter(
content_type=content_type,
object_id=self.pk,
)
else:
return CidUserExam.objects.filter(
content_type=content_type, object_id=self.pk, cid_user=cid_user
)
class NoteType(models.Model): class NoteType(models.Model):
note_type = models.CharField(max_length=200) note_type = models.CharField(max_length=200)
@@ -248,7 +283,7 @@ class CidUser(models.Model):
return str(self.cid) return str(self.cid)
def get_absolute_url(self): def get_absolute_url(self):
return reverse('generic:manage_cids') return reverse("generic:manage_cids")
def check_passcode(self, passcode) -> bool: def check_passcode(self, passcode) -> bool:
return self.passcode == passcode return self.passcode == passcode
@@ -332,7 +367,7 @@ class CidUser(models.Model):
class CidUserExam(models.Model): class CidUserExam(models.Model):
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField() object_id = models.PositiveIntegerField()
question = GenericForeignKey("content_type", "object_id") exam = GenericForeignKey("content_type", "object_id")
start_time = models.DateTimeField(blank=True, null=True) start_time = models.DateTimeField(blank=True, null=True)
end_time = models.DateTimeField(blank=True, null=True) end_time = models.DateTimeField(blank=True, null=True)
+13
View File
@@ -7,6 +7,7 @@ from django.forms.models import model_to_dict
from django.shortcuts import render, get_object_or_404, redirect from django.shortcuts import render, get_object_or_404, redirect
from django.contrib.auth.decorators import login_required, user_passes_test from django.contrib.auth.decorators import login_required, user_passes_test
from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from django.core.exceptions import PermissionDenied, FieldError from django.core.exceptions import PermissionDenied, FieldError
@@ -850,6 +851,18 @@ class ExamViews(View, LoginRequiredMixin):
# print(UserAnswer.objects.filter(exam__id=q["eid"])) # print(UserAnswer.objects.filter(exam__id=q["eid"]))
# print(request.urlencode()) # print(request.urlencode())
exam_type, exam_id = request.POST.get("eid").split("/")
c = CidUser.objects.filter(cid=request.POST.get("cid")).first()
exam = self.Exam.objects.get(pk=exam_id)
t = timezone.make_aware(datetime.fromtimestamp(float(request.POST.get("start_time"))))
cid_user_exam = exam.get_or_create_cid_user_exam(
cid_user=c, start_time=t
)
cid_user_exam.end_time = timezone.now()
cid_user_exam.save()
return JsonResponse({"success": True, "question_count": n}) return JsonResponse({"success": True, "question_count": n})
return JsonResponse({"success": False, "error": "Invalid data"}) return JsonResponse({"success": False, "error": "Invalid data"})
+109 -2
View File
@@ -2,6 +2,28 @@
{% block content %} {% block content %}
CID: {{cid}} CID: {{cid}}
<div id="timeup">
Allocated time over.
</div>
<div id="clockdiv">
Time remaining:
{% comment %} <div>
<span class="days"></span>
<div class="smalltext">Days</div>
</div> {% endcomment %}
<div>
<span class="hours num"></span>
<span class="smalltext">Hours</span>
</div>
<div>
<span class="minutes num"></span>
<span class="smalltext">Minutes</span>
</div>
<div>
<span class="seconds num"></span>
<span class="smalltext">Seconds</span>
</div>
</div>
<div class="no-select"> <div class="no-select">
<h2>{{exam.name}}: Question [{{pos|add:1}}/{{exam_length}}]</h2> <h2>{{exam.name}}: Question [{{pos|add:1}}/{{exam_length}}]</h2>
{% if exam.publish_results %} {% if exam.publish_results %}
@@ -100,6 +122,13 @@
// $(el).addClass("selected") // $(el).addClass("selected")
// }) // })
//}) //})
let time_limit = '{{exam_time_limit}}'
if (time_limit != "None") {
let end_time = new Date({{cid_user_exam.start_time|date:"U"}}*1000+{{exam.time_limit}}*1000);
initializeClock("clockdiv", end_time);
}
{% else %} {% else %}
$(`ol.physics-answer-list input`).each((n, el) => { el.disabled = true; }); $(`ol.physics-answer-list input`).each((n, el) => { el.disabled = true; });
{{question.get_answers_js}}.forEach((el, n) => { {{question.get_answers_js}}.forEach((el, n) => {
@@ -134,7 +163,54 @@
$("#goto-button").click(); $("#goto-button").click();
}); });
/* beautify ignore:end */ /* beautify ignore:end */
}) })
function getTimeRemaining(endtime) {
const total = Date.parse(endtime) - Date.parse(new Date());
const seconds = Math.floor((total / 1000) % 60);
const minutes = Math.floor((total / 1000 / 60) % 60);
const hours = Math.floor((total / (1000 * 60 * 60)) % 24);
//const days = Math.floor(total / (1000 * 60 * 60 * 24));
return {
total,
//days,
hours,
minutes,
seconds
};
}
function initializeClock(id, endtime) {
const clock = document.getElementById(id);
clock.style.display = "block"
const daysSpan = clock.querySelector('.days');
const hoursSpan = clock.querySelector('.hours');
const minutesSpan = clock.querySelector('.minutes');
const secondsSpan = clock.querySelector('.seconds');
function updateClock() {
const t = getTimeRemaining(endtime);
//daysSpan.innerHTML = t.days;
hoursSpan.innerHTML = ('0' + t.hours).slice(-2);
minutesSpan.innerHTML = ('0' + t.minutes).slice(-2);
secondsSpan.innerHTML = ('0' + t.seconds).slice(-2);
if (t.total <= 0) {
clearInterval(timeinterval);
timeup.style.display = "block"
clock.style.display = "none"
}
}
const timeinterval = setInterval(updateClock, 1000);
updateClock();
}
</script> </script>
{% endblock %} {% endblock %}
{% block css %} {% block css %}
@@ -160,12 +236,12 @@
.answer-true::after { .answer-true::after {
content: "[Correct answer: True]"; content: "[Correct answer: True]";
color: purple; color: darkgray;
} }
.answer-false::after { .answer-false::after {
content: "[Correct answer: False]"; content: "[Correct answer: False]";
color: purple; color: darkgray;
} }
.answer-correct { .answer-correct {
@@ -183,5 +259,36 @@
margin-left: 20px; margin-left: 20px;
content: "False" content: "False"
} }
#clockdiv{
font-family: sans-serif;
display: inline-block;
font-weight: 100;
text-align: center;
font-size: 12px;
display: none;
color: darkgray;
}
#clockdiv > div{
padding: 1px;
border-radius: 3px;
{% comment %} background: purple; {% endcomment %}
display: inline-block;
}
#clockdiv div > span.num{
color: white;
padding: 1px;
border-radius: 3px;
display: inline-block;
}
#timeup {
display: none;
color: red;
font-weight: 100;
text-align: center;
font-size: 12px;
}
</style> </style>
{% endblock %} {% endblock %}
@@ -18,6 +18,8 @@
<a href="{% url 'physics:exam_take' pk=exam.id sk=forloop.counter0 cid=cid passcode=passcode %}"><button {% if not answer %}class="unanswered"{% endif %}>{{forloop.counter}}: {{answer.answer}}</button></a> <a href="{% url 'physics:exam_take' pk=exam.id sk=forloop.counter0 cid=cid passcode=passcode %}"><button {% if not answer %}class="unanswered"{% endif %}>{{forloop.counter}}: {{answer.answer}}</button></a>
{% endfor %} {% endfor %}
</div> </div>
Start time: {{cid_user_exam.start_time}}<br/>
Last change time: {{cid_user_exam.end_time}}
{% endblock %} {% endblock %}
+3 -1
View File
@@ -3,5 +3,7 @@
{% block navigation %} {% block navigation %}
{{block.super}} {{block.super}}
<br/> <br/>
Exams: {{exam.name}}-> <a href="{% url 'physics:exam_overview' pk=exam.pk %}">Overview</a> / <a href="{% url 'physics:exam_scores_cid' pk=exam.pk %}">Scores</a> Exams: {{exam.name}}-> <a href="{% url 'physics:exam_overview' pk=exam.pk %}">Overview</a> /
<a href="{% url 'physics:exam_scores_cid' pk=exam.pk %}">Scores</a> /
<a href="{% url 'physics:exam_cids' exam_id=exam.pk %}">Candidates</a>
{% endblock %} {% endblock %}
+7 -3
View File
@@ -22,11 +22,15 @@ urlpatterns = [
path("exam/<int:pk>/start", views.exam_start, name="exam_start"), path("exam/<int:pk>/start", views.exam_start, name="exam_start"),
path( path(
"exam/<int:pk>/<str:cid>/<str:passcode>/finish", "exam/<int:pk>/<str:cid>/<str:passcode>/finish",
views.exam_finish, views.exam_take_overview,
name="exam_finish", name="exam_take_overview",
), ),
path("exam/<int:pk>/", views.GenericExamViews.exam_overview, name="exam_overview"), path("exam/<int:pk>/", views.GenericExamViews.exam_overview, name="exam_overview"),
path("exam/<int:pk>/scores", views.GenericExamViews.exam_scores_cid, name="exam_scores_cid"), path(
"exam/<int:pk>/scores",
views.GenericExamViews.exam_scores_cid,
name="exam_scores_cid",
),
path( path(
"exam/<int:pk>/scores/<int:cid>/<str:passcode>/", "exam/<int:pk>/scores/<int:cid>/<str:passcode>/",
views.exam_scores_cid_user, views.exam_scores_cid_user,
+19 -5
View File
@@ -1,3 +1,6 @@
from django.contrib.contenttypes.models import ContentType
from django.utils import timezone
from generic.models import CidUser
from physics.decorators import user_is_author_or_physics_checker from physics.decorators import user_is_author_or_physics_checker
from physics.filters import QuestionFilter, UserAnswerFilter from physics.filters import QuestionFilter, UserAnswerFilter
from generic.mixins import SuperuserRequiredMixin from generic.mixins import SuperuserRequiredMixin
@@ -178,9 +181,12 @@ def exam_start(request, pk):
) )
def exam_finish(request, pk, cid, passcode): def exam_take_overview(request, pk, cid, passcode):
exam = get_object_or_404(Exam, pk=pk) exam = get_object_or_404(Exam, pk=pk)
if not exam.active:
raise Http404("Exam not found")
if not exam.check_cid_user(cid, passcode): if not exam.check_cid_user(cid, passcode):
raise Http404("Error accessing exam") raise Http404("Error accessing exam")
@@ -202,12 +208,12 @@ def exam_finish(request, pk, cid, passcode):
else: else:
question_answer_tuples.append((q, None)) question_answer_tuples.append((q, None))
if not exam.active: c = CidUser.objects.filter(cid=cid).first()
raise Http404("Exam not found") cid_user_exam = exam.get_or_create_cid_user_exam(cid_user=c)
return render( return render(
request, request,
"physics/exam_finish.html", "physics/exam_take_overview.html",
{ {
"exam": exam, "exam": exam,
"cid": cid, "cid": cid,
@@ -215,6 +221,7 @@ def exam_finish(request, pk, cid, passcode):
"answer_count": answer_count, "answer_count": answer_count,
"exam_length": len(questions), "exam_length": len(questions),
"passcode": passcode, "passcode": passcode,
"cid_user_exam": cid_user_exam,
}, },
) )
@@ -229,6 +236,9 @@ def exam_take(request, pk, sk, cid, passcode):
if not exam.check_cid_user(cid, passcode): if not exam.check_cid_user(cid, passcode):
raise Http404("Error accessing exam") raise Http404("Error accessing exam")
c = CidUser.objects.filter(cid=cid).first()
cid_user_exam = exam.get_or_create_cid_user_exam(cid_user=c)
question = exam.exam_questions.all()[sk] question = exam.exam_questions.all()[sk]
exam_length = len(exam.exam_questions.all()) exam_length = len(exam.exam_questions.all())
@@ -250,9 +260,12 @@ def exam_take(request, pk, sk, cid, passcode):
# answer.published_date = timezone.now() # answer.published_date = timezone.now()
answer.save() answer.save()
cid_user_exam.end_time = timezone.now()
cid_user_exam.save()
kwargs = {"pk": pk, "cid": cid, "passcode": passcode} kwargs = {"pk": pk, "cid": cid, "passcode": passcode}
take_url = "physics:exam_take" take_url = "physics:exam_take"
finish_url = "physics:exam_finish" finish_url = "physics:exam_take_overview"
if "next" in request.POST: if "next" in request.POST:
return redirect(take_url, sk=pos + 1, **kwargs) return redirect(take_url, sk=pos + 1, **kwargs)
@@ -289,6 +302,7 @@ def exam_take(request, pk, sk, cid, passcode):
"exam_length": exam_length, "exam_length": exam_length,
"pos": pos, "pos": pos,
"passcode": passcode, "passcode": passcode,
"cid_user_exam": cid_user_exam,
}, },
) )
@@ -13,6 +13,8 @@
{% endfor %} {% endfor %}
</div> </div>
Start time: {{cid_user_exam.start_time}}
{% endblock %} {% endblock %}
{% block js %} {% block js %}
+2 -2
View File
@@ -22,8 +22,8 @@ urlpatterns = [
path("exam/<int:pk>/start", views.exam_start, name="exam_start"), path("exam/<int:pk>/start", views.exam_start, name="exam_start"),
path( path(
"exam/<int:pk>/<str:cid>/<str:passcode>/finish", "exam/<int:pk>/<str:cid>/<str:passcode>/finish",
views.exam_finish, views.exam_take_overview,
name="exam_finish", name="exam_take_overview",
), ),
path("exam/<int:pk>/", views.GenericExamViews.exam_overview, name="exam_overview"), path("exam/<int:pk>/", views.GenericExamViews.exam_overview, name="exam_overview"),
path( path(
+20 -6
View File
@@ -1,6 +1,7 @@
from reversion.views import RevisionMixin from reversion.views import RevisionMixin
from generic.mixins import SuperuserRequiredMixin from generic.mixins import SuperuserRequiredMixin
from django.views.generic.detail import DetailView from django.views.generic.detail import DetailView
from generic.models import CidUser
from sbas.forms import CidUserAnswerForm, ExamForm from sbas.forms import CidUserAnswerForm, ExamForm
from django.shortcuts import render, get_object_or_404, redirect from django.shortcuts import render, get_object_or_404, redirect
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
@@ -156,9 +157,12 @@ def exam_start(request, pk):
) )
def exam_finish(request, pk, cid, passcode): def exam_take_overview(request, pk, cid, passcode):
exam = get_object_or_404(Exam, pk=pk) exam = get_object_or_404(Exam, pk=pk)
if not exam.active:
raise Http404("Exam not found")
if not exam.check_cid_user(cid, passcode): if not exam.check_cid_user(cid, passcode):
raise Http404("Error accessing exam") raise Http404("Error accessing exam")
@@ -179,12 +183,12 @@ def exam_finish(request, pk, cid, passcode):
else: else:
question_answer_tuples.append((q, None)) question_answer_tuples.append((q, None))
if not exam.active: c = CidUser.objects.filter(cid=cid).first()
raise Http404("Exam not found") cid_user_exam = exam.get_or_create_cid_user_exam(cid_user=c)
return render( return render(
request, request,
"sbas/exam_finish.html", "sbas/exam_take_overview.html",
{ {
"exam": exam, "exam": exam,
"cid": cid, "cid": cid,
@@ -192,6 +196,7 @@ def exam_finish(request, pk, cid, passcode):
"answer_count": answer_count, "answer_count": answer_count,
"exam_length": len(questions), "exam_length": len(questions),
"passcode": passcode, "passcode": passcode,
"cid_user_exam": cid_user_exam,
}, },
) )
@@ -206,6 +211,9 @@ def exam_take(request, pk, sk, cid, passcode):
if not exam.check_cid_user(cid, passcode): if not exam.check_cid_user(cid, passcode):
raise Http404("Error accessing exam") raise Http404("Error accessing exam")
c = CidUser.objects.filter(cid=cid).first()
cid_user_exam = exam.get_or_create_cid_user_exam(cid_user=c)
question = exam.exam_questions.all()[sk] question = exam.exam_questions.all()[sk]
exam_length = len(exam.exam_questions.all()) exam_length = len(exam.exam_questions.all())
@@ -227,6 +235,9 @@ def exam_take(request, pk, sk, cid, passcode):
# answer.published_date = timezone.now() # answer.published_date = timezone.now()
answer.save() answer.save()
cid_user_exam.end_time = timezone.now()
cid_user_exam.save()
kwargs = {"pk": pk, "cid": cid, "passcode": passcode} kwargs = {"pk": pk, "cid": cid, "passcode": passcode}
if "next" in request.POST: if "next" in request.POST:
@@ -234,7 +245,7 @@ def exam_take(request, pk, sk, cid, passcode):
elif "previous" in request.POST: elif "previous" in request.POST:
return redirect("sbas:exam_take", sk=pos - 1, **kwargs) return redirect("sbas:exam_take", sk=pos - 1, **kwargs)
elif "finish" in request.POST: elif "finish" in request.POST:
return redirect("sbas:exam_finish", **kwargs) return redirect("sbas:exam_take_overview", **kwargs)
elif "goto" in request.POST: elif "goto" in request.POST:
return redirect( return redirect(
"sbas:exam_take", sk=int(request.POST.get("goto")), **kwargs "sbas:exam_take", sk=int(request.POST.get("goto")), **kwargs
@@ -267,6 +278,7 @@ def exam_take(request, pk, sk, cid, passcode):
"pos": pos, "pos": pos,
"saved_answer": saved_answer, "saved_answer": saved_answer,
"passcode": passcode, "passcode": passcode,
"cid_user_exam": cid_user_exam,
}, },
) )
@@ -321,6 +333,7 @@ class UserAnswerDelete(SuperuserRequiredMixin, DeleteView):
GenericViews = GenericViewBase("sbas", Question, CidUserAnswer, Exam) GenericViews = GenericViewBase("sbas", Question, CidUserAnswer, Exam)
class QuestionDelete(AuthorOrCheckerRequiredMixin, DeleteView): class QuestionDelete(AuthorOrCheckerRequiredMixin, DeleteView):
model = Question model = Question
success_url = reverse_lazy("sbas:question_list") success_url = reverse_lazy("sbas:question_list")
@@ -352,7 +365,7 @@ class ExamUpdate(
return super().form_valid(form) return super().form_valid(form)
#class ExamViewSet(RevisionMixin, viewsets.ModelViewSet): # class ExamViewSet(RevisionMixin, viewsets.ModelViewSet):
# # queryset = Exam.objects.all().order_by('name') # # queryset = Exam.objects.all().order_by('name')
# serializer_class = ExamSerializer # serializer_class = ExamSerializer
# permission_classes = [permissions.IsAuthenticated] # permission_classes = [permissions.IsAuthenticated]
@@ -373,5 +386,6 @@ class ExamDelete(RevisionMixin, AuthorOrCheckerRequiredMixin, DeleteView):
template_name = "exam_confirm_delete.html" template_name = "exam_confirm_delete.html"
success_url = reverse_lazy("sbas:index") success_url = reverse_lazy("sbas:index")
class ExamClone(ExamCloneMixin, ExamCreate): class ExamClone(ExamCloneMixin, ExamCreate):
"""Clone exam view""" """Clone exam view"""