.
This commit is contained in:
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 3.2.13 on 2022-06-26 15:12
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('anatomy', '0064_auto_20220521_1020'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='exam',
|
||||||
|
name='exam_results_emailed',
|
||||||
|
field=models.DateTimeField(default=None, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 3.2.13 on 2022-06-26 15:29
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('generic', '0035_userprofile_grade'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='ciduserexam',
|
||||||
|
name='results_emailed_status',
|
||||||
|
field=models.CharField(blank=True, max_length=255),
|
||||||
|
),
|
||||||
|
]
|
||||||
+95
-7
@@ -145,6 +145,8 @@ class ExamBase(models.Model):
|
|||||||
# Is this actually used?
|
# Is this actually used?
|
||||||
cid_users = GenericRelation("generic.CidUserExam")
|
cid_users = GenericRelation("generic.CidUserExam")
|
||||||
|
|
||||||
|
exam_results_emailed = models.DateTimeField(default=None, null=True)
|
||||||
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
abstract = True
|
abstract = True
|
||||||
@@ -153,11 +155,14 @@ class ExamBase(models.Model):
|
|||||||
self.recreate_json = recreate_json
|
self.recreate_json = recreate_json
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
def get_exam_stats(self):
|
def get_exam_stats(self, name=True):
|
||||||
text = f"""{self.name}
|
text = f"""- Candidates: {int(self.stats_candidates)}
|
||||||
{"-"*len(self.name)}
|
|
||||||
- Candidates: {int(self.stats_candidates)}
|
|
||||||
- Mean: {self.stats_mean:.2f} Median: {self.stats_median} Mode: {self.stats_mode} [Min: {self.stats_min} / Max: {self.stats_max}]
|
- Mean: {self.stats_mean:.2f} Median: {self.stats_median} Mode: {self.stats_mode} [Min: {self.stats_min} / Max: {self.stats_max}]
|
||||||
|
"""
|
||||||
|
if name:
|
||||||
|
text = f"""{self.name}
|
||||||
|
{"-"*len(self.name)}
|
||||||
|
{text}
|
||||||
"""
|
"""
|
||||||
return text
|
return text
|
||||||
|
|
||||||
@@ -223,7 +228,7 @@ class ExamBase(models.Model):
|
|||||||
|
|
||||||
if user_user is not None:
|
if user_user is not None:
|
||||||
c = CidUserExam.objects.filter(
|
c = CidUserExam.objects.filter(
|
||||||
content_type=content_type, object_id=self.pk, user_user=cid_user
|
content_type=content_type, object_id=self.pk, user_user=user_user
|
||||||
).first()
|
).first()
|
||||||
if c:
|
if c:
|
||||||
return c
|
return c
|
||||||
@@ -270,12 +275,93 @@ class ExamBase(models.Model):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def get_cid_user_score(self, cid_user):
|
def get_cid_user_score(self, cid_user):
|
||||||
c = str(cid_user)
|
c = "c/"+str(cid_user)
|
||||||
if c in self.user_scores:
|
if c in self.user_scores:
|
||||||
return self.user_scores[c]
|
return self.user_scores[c]
|
||||||
else:
|
else:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def generate_user_report(self, user):
|
||||||
|
exam_text = [f"Candidate {user.first_name} [{user.email}]"]
|
||||||
|
|
||||||
|
exam_name = self.name
|
||||||
|
|
||||||
|
exam_text.append(f"{'='*len(exam_name)}\n{exam_name}\n{'='*len(exam_name)}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
user_score = self.user_scores["u/"+str(user.pk)]["score"]
|
||||||
|
|
||||||
|
if self.app_name == "rapids":
|
||||||
|
callstate = self.user_scores["u/"+str(user.pk)]["callstates"]
|
||||||
|
except KeyError:
|
||||||
|
user_score = "Score not generated"
|
||||||
|
|
||||||
|
try:
|
||||||
|
percentage = (
|
||||||
|
f" ({user_score / int(self.stats_max_possible) * 100:.2f}%)"
|
||||||
|
)
|
||||||
|
except TypeError:
|
||||||
|
percentage = ""
|
||||||
|
|
||||||
|
|
||||||
|
exam_text.append(
|
||||||
|
f"""Results\n{'-'*len('Results')}
|
||||||
|
# Score: {user_score} / {int(self.stats_max_possible)}{percentage}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
stats = self.get_exam_stats(name=False)
|
||||||
|
exam_text.append(f"Stats\n{'-'*len('Stats')}\n{stats}")
|
||||||
|
|
||||||
|
|
||||||
|
exam_text.append(
|
||||||
|
f"Answers breakdown: {callstate}"
|
||||||
|
)
|
||||||
|
|
||||||
|
msg = "\n\n".join(exam_text)
|
||||||
|
|
||||||
|
return msg
|
||||||
|
|
||||||
|
def email_user_results(self, user, resend=False, additional_emails=[]):
|
||||||
|
print("email_user_reuslts:", user)
|
||||||
|
|
||||||
|
#if self.results_email_sent and not resend:
|
||||||
|
# return False, "Already sent."
|
||||||
|
|
||||||
|
# Get a list of taken exams
|
||||||
|
msg = self.generate_user_report(user)
|
||||||
|
|
||||||
|
print(msg)
|
||||||
|
|
||||||
|
if not user.email:
|
||||||
|
return False, "User has no email"
|
||||||
|
|
||||||
|
emails = [user.email]
|
||||||
|
|
||||||
|
supervisor_email = user.userprofile.supervisor_email
|
||||||
|
|
||||||
|
if supervisor_email:
|
||||||
|
emails.append(supervisor_email)
|
||||||
|
|
||||||
|
|
||||||
|
if additional_emails:
|
||||||
|
emails.extend(additional_emails)
|
||||||
|
|
||||||
|
try:
|
||||||
|
send_mail(
|
||||||
|
f"{self.name} results",
|
||||||
|
msg,
|
||||||
|
"no-reply@penracourses.org.uk",
|
||||||
|
emails,
|
||||||
|
fail_silently=False,
|
||||||
|
# html_message=html_msg,
|
||||||
|
)
|
||||||
|
except SMTPException as e:
|
||||||
|
return False, e
|
||||||
|
|
||||||
|
#self.results_email_sent = True
|
||||||
|
#self.save()
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
|
||||||
class NoteType(models.Model):
|
class NoteType(models.Model):
|
||||||
note_type = models.CharField(max_length=200)
|
note_type = models.CharField(max_length=200)
|
||||||
@@ -396,7 +482,7 @@ class CidUser(models.Model):
|
|||||||
|
|
||||||
for exam in exams:
|
for exam in exams:
|
||||||
try:
|
try:
|
||||||
user_score = exam.user_scores[str(self.cid)]
|
user_score = exam.user_scores["c/"+str(self.cid)]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
user_score = "Score not generated"
|
user_score = "Score not generated"
|
||||||
t = exam.get_exam_stats()
|
t = exam.get_exam_stats()
|
||||||
@@ -523,6 +609,8 @@ class CidUserExam(models.Model):
|
|||||||
|
|
||||||
manual_submission = models.BooleanField(default=False)
|
manual_submission = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
results_emailed_status = models.CharField(max_length=255, blank=True)
|
||||||
|
|
||||||
|
|
||||||
class CidUserGroup(models.Model):
|
class CidUserGroup(models.Model):
|
||||||
name = models.CharField(blank=True, max_length=50)
|
name = models.CharField(blank=True, max_length=50)
|
||||||
|
|||||||
+152
-33
@@ -1,4 +1,4 @@
|
|||||||
from collections import defaultdict
|
from collections import defaultdict, Counter
|
||||||
import statistics
|
import statistics
|
||||||
import threading
|
import threading
|
||||||
from dal import autocomplete
|
from dal import autocomplete
|
||||||
@@ -294,7 +294,9 @@ class ExamViews(View, LoginRequiredMixin):
|
|||||||
# exam_group = group_map[self.app_name]
|
# exam_group = group_map[self.app_name]
|
||||||
|
|
||||||
class ExamFilter(FilterSet):
|
class ExamFilter(FilterSet):
|
||||||
sort_order = OrderingFilter(fields=(("name", "name"), ("exam_mode", "exam_mode")))
|
sort_order = OrderingFilter(
|
||||||
|
fields=(("name", "name"), ("exam_mode", "exam_mode"))
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = exam
|
model = exam
|
||||||
@@ -529,16 +531,15 @@ class ExamViews(View, LoginRequiredMixin):
|
|||||||
def exam_list(self, request, all=False):
|
def exam_list(self, request, all=False):
|
||||||
|
|
||||||
if not self.check_user_access(request.user):
|
if not self.check_user_access(request.user):
|
||||||
#raise PermissionDenied
|
# raise PermissionDenied
|
||||||
exam_list = self.Exam.objects.filter(author__id=request.user.id).order_by("name")
|
exam_list = self.Exam.objects.filter(author__id=request.user.id).order_by(
|
||||||
|
"name"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
exam_list = self.Exam.objects.all().order_by("name")
|
exam_list = self.Exam.objects.all().order_by("name")
|
||||||
|
|
||||||
if not all:
|
if not all:
|
||||||
exams = exam_list.filter(exam_mode=True, archive=False).order_by(
|
exams = exam_list.filter(exam_mode=True, archive=False).order_by("name")
|
||||||
"name"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
marking = False
|
marking = False
|
||||||
|
|
||||||
@@ -556,6 +557,90 @@ class ExamViews(View, LoginRequiredMixin):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@method_decorator(login_required)
|
||||||
|
def exam_report_email_status(self, request, exam_id):
|
||||||
|
exam = get_object_or_404(self.Exam.objects.prefetch_related("author"), pk=exam_id)
|
||||||
|
|
||||||
|
if request.user not in exam.author.all():
|
||||||
|
if not self.check_user_access(request.user, exam_id):
|
||||||
|
raise PermissionDenied
|
||||||
|
|
||||||
|
users = [i[2:] for i in exam.user_scores if i.startswith("u/")]
|
||||||
|
|
||||||
|
status = {}
|
||||||
|
for u in users:
|
||||||
|
user = User.objects.get(pk=u)
|
||||||
|
user_exam = exam.get_or_create_cid_user_exam(user_user=user)
|
||||||
|
status[user.username] = user_exam.results_emailed_status
|
||||||
|
|
||||||
|
return JsonResponse(status)
|
||||||
|
|
||||||
|
|
||||||
|
@method_decorator(login_required)
|
||||||
|
def exam_report_email(self, request, exam_id):
|
||||||
|
exam = get_object_or_404(self.Exam.objects.prefetch_related("author"), pk=exam_id)
|
||||||
|
|
||||||
|
if request.user not in exam.author.all():
|
||||||
|
if not self.check_user_access(request.user, exam_id):
|
||||||
|
raise PermissionDenied
|
||||||
|
|
||||||
|
|
||||||
|
# We only need to send emails to those who have scores
|
||||||
|
# (who should be in exam.user_scores)
|
||||||
|
users = [i[2:] for i in exam.user_scores if i.startswith("u/")]
|
||||||
|
|
||||||
|
time = timezone.now()
|
||||||
|
|
||||||
|
email_results = {}
|
||||||
|
for u in users:
|
||||||
|
user = User.objects.get(pk=u)
|
||||||
|
email_results[user.username] = exam.email_user_results(user)
|
||||||
|
|
||||||
|
user_exam = exam.get_or_create_cid_user_exam(user_user=user)
|
||||||
|
user_exam.results_emailed_status = json.dumps(email_results[user.username]) + f" {time}"
|
||||||
|
user_exam.save()
|
||||||
|
|
||||||
|
exam.exam_results_emailed = time
|
||||||
|
exam.save()
|
||||||
|
|
||||||
|
return JsonResponse(email_results)
|
||||||
|
|
||||||
|
@method_decorator(login_required)
|
||||||
|
def exam_user_report_email(self, request, exam_id, user_id):
|
||||||
|
# print(Exam.objects.all())
|
||||||
|
# exams = Exam.objects.all()
|
||||||
|
# print("test", Exam.objects.all().get(id=pk))
|
||||||
|
exam = get_object_or_404(self.Exam.objects.prefetch_related("author"), pk=exam_id)
|
||||||
|
|
||||||
|
u = get_object_or_404(User, pk=user_id)
|
||||||
|
|
||||||
|
if request.user not in exam.author.all():
|
||||||
|
if not self.check_user_access(request.user, exam_id):
|
||||||
|
raise PermissionDenied
|
||||||
|
|
||||||
|
res = exam.email_user_results(u)
|
||||||
|
|
||||||
|
return HttpResponse(res)
|
||||||
|
|
||||||
|
@method_decorator(login_required)
|
||||||
|
def exam_user_report(self, request, exam_id, user_id):
|
||||||
|
# print(Exam.objects.all())
|
||||||
|
# exams = Exam.objects.all()
|
||||||
|
# print("test", Exam.objects.all().get(id=pk))
|
||||||
|
exam = get_object_or_404(self.Exam.objects.prefetch_related("author"), pk=exam_id)
|
||||||
|
|
||||||
|
u = get_object_or_404(User, pk=user_id)
|
||||||
|
|
||||||
|
if request.user not in exam.author.all():
|
||||||
|
if not self.check_user_access(request.user, exam_id):
|
||||||
|
raise PermissionDenied
|
||||||
|
|
||||||
|
res = exam.generate_user_report(u)
|
||||||
|
print(res)
|
||||||
|
|
||||||
|
return HttpResponse(res)
|
||||||
|
|
||||||
|
|
||||||
@method_decorator(login_required)
|
@method_decorator(login_required)
|
||||||
def exam_overview(self, request, pk):
|
def exam_overview(self, request, pk):
|
||||||
# print(Exam.objects.all())
|
# print(Exam.objects.all())
|
||||||
@@ -563,6 +648,7 @@ class ExamViews(View, LoginRequiredMixin):
|
|||||||
# print("test", Exam.objects.all().get(id=pk))
|
# print("test", Exam.objects.all().get(id=pk))
|
||||||
exam = get_object_or_404(self.Exam.objects.prefetch_related("author"), pk=pk)
|
exam = get_object_or_404(self.Exam.objects.prefetch_related("author"), pk=pk)
|
||||||
|
|
||||||
|
|
||||||
if request.user not in exam.author.all():
|
if request.user not in exam.author.all():
|
||||||
if not self.check_user_access(request.user, pk):
|
if not self.check_user_access(request.user, pk):
|
||||||
raise PermissionDenied
|
raise PermissionDenied
|
||||||
@@ -1396,6 +1482,10 @@ class ExamViews(View, LoginRequiredMixin):
|
|||||||
by_question = defaultdict(dict)
|
by_question = defaultdict(dict)
|
||||||
unmarked = set()
|
unmarked = set()
|
||||||
|
|
||||||
|
if self.app_name in ("rapids"):
|
||||||
|
user_answers_callstates = defaultdict(list)
|
||||||
|
user_answers_callstates_counted = {}
|
||||||
|
|
||||||
if self.app_name in ("physics", "sbas"):
|
if self.app_name in ("physics", "sbas"):
|
||||||
questions = exam.exam_questions.all()
|
questions = exam.exam_questions.all()
|
||||||
else:
|
else:
|
||||||
@@ -1408,15 +1498,18 @@ class ExamViews(View, LoginRequiredMixin):
|
|||||||
|
|
||||||
cids = set()
|
cids = set()
|
||||||
|
|
||||||
|
cids_user_id_map = {}
|
||||||
|
|
||||||
# Loop through all candidates
|
# Loop through all candidates
|
||||||
for cid_user_answer in cid_user_answers:
|
for cid_user_answer in cid_user_answers:
|
||||||
# Convoluted (probably...)
|
# Convoluted (probably...)
|
||||||
if cid_user_answer.user is None:
|
if cid_user_answer.user is None:
|
||||||
cid = cid_user_answer.cid
|
cid = cid_user_answer.cid
|
||||||
# cid_passcodes[cid] = cid_user_answer.passcode
|
# cid_passcodes[cid] = cid_user_answer.passcode
|
||||||
cids.add(cid)
|
cids.add("c/"+cid)
|
||||||
else:
|
else:
|
||||||
cid = f"u/{cid_user_answer.user.username}"
|
cid = f"u/{cid_user_answer.user.pk}"
|
||||||
|
cids_user_id_map[cid] = cid_user_answer.user.username
|
||||||
cids.add(cid)
|
cids.add(cid)
|
||||||
|
|
||||||
s = cid_user_answer
|
s = cid_user_answer
|
||||||
@@ -1439,9 +1532,15 @@ class ExamViews(View, LoginRequiredMixin):
|
|||||||
unmarked.add(index)
|
unmarked.add(index)
|
||||||
user_answers[cid].append(ans)
|
user_answers[cid].append(ans)
|
||||||
user_answers_marks[cid].append(answer_score)
|
user_answers_marks[cid].append(answer_score)
|
||||||
# user_answers_and_marks[cid].append((ans, answer_score))
|
|
||||||
|
|
||||||
if self.app_name in ("rapids", "anatomy", "sbas"):
|
if self.app_name == "rapids":
|
||||||
|
callstate = s.get_answer_callstate()
|
||||||
|
print(cid, callstate)
|
||||||
|
by_question[q][cid] = (ans, answer_score, callstate)
|
||||||
|
user_answers_callstates[cid].append(callstate)
|
||||||
|
print(user_answers_callstates)
|
||||||
|
print("CID:::",cid, user_answers_callstates[cid])
|
||||||
|
elif self.app_name in ("rapids", "anatomy", "sbas"):
|
||||||
by_question[q][cid] = (ans, answer_score)
|
by_question[q][cid] = (ans, answer_score)
|
||||||
else:
|
else:
|
||||||
zipped_ans_scores = zip(ans, answer_score)
|
zipped_ans_scores = zip(ans, answer_score)
|
||||||
@@ -1513,33 +1612,53 @@ class ExamViews(View, LoginRequiredMixin):
|
|||||||
|
|
||||||
exam.stats_graph = fig_html
|
exam.stats_graph = fig_html
|
||||||
|
|
||||||
exam.user_scores = user_scores
|
user_data = {}
|
||||||
|
for u in cids:
|
||||||
|
#uid = cids_user_id_map[u]
|
||||||
|
user_data[u] = {
|
||||||
|
"score": user_scores[u],
|
||||||
|
"normalised_score": user_scores_normalised[u],
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.app_name == "rapids":
|
||||||
|
counted_callstates = dict(Counter(user_answers_callstates[u]))
|
||||||
|
user_data[u]["callstates"] = str(counted_callstates)
|
||||||
|
user_answers_callstates_counted[u] = counted_callstates
|
||||||
|
|
||||||
|
print(user_data)
|
||||||
|
|
||||||
|
exam.user_scores = user_data
|
||||||
|
|
||||||
exam.save()
|
exam.save()
|
||||||
|
|
||||||
|
template_variables = {
|
||||||
|
"cids": sorted(cids),
|
||||||
|
# "cid_passcodes": cid_passcodes,
|
||||||
|
"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_normalised": user_scores_normalised,
|
||||||
|
# "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,
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.app_name == "rapids":
|
||||||
|
template_variables["user_answers_callstates"] = user_answers_callstates_counted
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
f"{self.app_name}/exam_scores_new.html",
|
f"{self.app_name}/exam_scores_new.html",
|
||||||
{
|
template_variables,
|
||||||
"cids": sorted(cids),
|
|
||||||
# "cid_passcodes": cid_passcodes,
|
|
||||||
"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_normalised": user_scores_normalised,
|
|
||||||
# "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,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 3.2.13 on 2022-06-26 15:12
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('longs', '0062_auto_20220521_2135'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='exam',
|
||||||
|
name='exam_results_emailed',
|
||||||
|
field=models.DateTimeField(default=None, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 3.2.13 on 2022-06-26 15:12
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('physics', '0024_alter_exam_cid_user_groups'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='exam',
|
||||||
|
name='exam_results_emailed',
|
||||||
|
field=models.DateTimeField(default=None, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
[pytest]
|
[pytest]
|
||||||
DJANGO_SETTINGS_MODULE=rad.settings
|
DJANGO_SETTINGS_MODULE=rad.settings
|
||||||
python_files = tests.py test_*.py *_tests.py
|
python_files = tests.py test_*.py *_tests.py
|
||||||
|
addopts = --nomigrations
|
||||||
+74
-43
@@ -23,39 +23,54 @@ from django.views.generic import TemplateView
|
|||||||
from . import views
|
from . import views
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
from rapids import views as rapid_views
|
from rapids import views as rapid_views
|
||||||
from anatomy import views as anatomy_views
|
from anatomy import views as anatomy_views
|
||||||
from longs import views as long_views
|
from longs import views as long_views
|
||||||
|
|
||||||
from rest_framework import routers
|
from rest_framework import routers
|
||||||
|
|
||||||
from django.conf.urls import (
|
from django.conf.urls import handler400, handler403, handler404, handler500
|
||||||
handler400, handler403, handler404, handler500
|
|
||||||
)
|
|
||||||
|
|
||||||
router = routers.DefaultRouter()
|
router = routers.DefaultRouter()
|
||||||
router.register(r'rapids/exams', rapid_views.ExamViewSet, basename="rapid-exam")
|
router.register(r"rapids/exams", rapid_views.ExamViewSet, basename="rapid-exam")
|
||||||
router.register(r'anatomy/exams', anatomy_views.ExamViewSet, basename="anatomy-exam")
|
router.register(r"anatomy/exams", anatomy_views.ExamViewSet, basename="anatomy-exam")
|
||||||
router.register(r'longs/exams', long_views.ExamViewSet, basename="long-exam")
|
router.register(r"longs/exams", long_views.ExamViewSet, basename="long-exam")
|
||||||
router.register(r'rapids/answers', rapid_views.QuestionAnswerViewSet, basename="rapid-question-answers")
|
router.register(
|
||||||
router.register(r'rapids/user_answer', rapid_views.UserAnswerViewSet, basename="rapid-question-answers")
|
r"rapids/answers",
|
||||||
router.register(r'rapids', rapid_views.RapidViewSet, basename="rapid")
|
rapid_views.QuestionAnswerViewSet,
|
||||||
#router.register(r'rapids/laterality', rapid_views.RapidLateralityViewSet, basename="rapid")
|
basename="rapid-question-answers",
|
||||||
|
)
|
||||||
|
router.register(
|
||||||
|
r"rapids/user_answer",
|
||||||
|
rapid_views.UserAnswerViewSet,
|
||||||
|
basename="rapid-question-answers",
|
||||||
|
)
|
||||||
|
router.register(r"rapids", rapid_views.RapidViewSet, basename="rapid")
|
||||||
|
# router.register(r'rapids/laterality', rapid_views.RapidLateralityViewSet, basename="rapid")
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("admin/", admin.site.urls, name="admin"),
|
path("admin/", admin.site.urls, name="admin"),
|
||||||
path("anatomy/", include("anatomy.urls"), name="anatomy"),
|
path("anatomy/", include("anatomy.urls"), name="anatomy"),
|
||||||
path("physics/", include("physics.urls"), name="physics"),
|
path("physics/", include("physics.urls"), name="physics"),
|
||||||
#path("sbas/", include("sbas.urls"), name="sbas"),
|
# path("sbas/", include("sbas.urls"), name="sbas"),
|
||||||
path("rapids/", include("rapids.urls"), name="rapids"),
|
path("rapids/", include("rapids.urls"), name="rapids"),
|
||||||
path("longs/", include("longs.urls"), name="longs"),
|
path("longs/", include("longs.urls"), name="longs"),
|
||||||
path("sbas/", include("sbas.urls"), name="sbas"),
|
path("sbas/", include("sbas.urls"), name="sbas"),
|
||||||
path("generic/", include("generic.urls"), name="generic"),
|
path("generic/", include("generic.urls"), name="generic"),
|
||||||
path("atlas/", include("atlas.urls"), name="atlas"),
|
path("atlas/", include("atlas.urls"), name="atlas"),
|
||||||
path("accounts/bulk_create/", views.accounts_bulk_create, name="accounts_bulk_create"),
|
path(
|
||||||
path("accounts/update/<str:slug>/", views.UpdateUserView.as_view(), name="account_update"),
|
"accounts/bulk_create/", views.accounts_bulk_create, name="accounts_bulk_create"
|
||||||
path("accounts/update_profile/<str:slug>/", views.UpdateUserProfileView.as_view(), name="account_profile_update"),
|
),
|
||||||
|
path(
|
||||||
|
"accounts/update/<str:slug>/",
|
||||||
|
views.UpdateUserView.as_view(),
|
||||||
|
name="account_update",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"accounts/update_profile/<str:slug>/",
|
||||||
|
views.UpdateUserProfileView.as_view(),
|
||||||
|
name="account_profile_update",
|
||||||
|
),
|
||||||
path("accounts/", views.UserListView.as_view(), name="accounts_list"),
|
path("accounts/", views.UserListView.as_view(), name="accounts_list"),
|
||||||
path("accounts/", include("django.contrib.auth.urls")),
|
path("accounts/", include("django.contrib.auth.urls")),
|
||||||
path("accounts/profile", views.profile, name="profile"),
|
path("accounts/profile", views.profile, name="profile"),
|
||||||
@@ -68,44 +83,60 @@ urlpatterns = [
|
|||||||
path("cid/", views.cid_selector, name="cid_selector"),
|
path("cid/", views.cid_selector, name="cid_selector"),
|
||||||
# Global url that registers RTS compatible exams
|
# Global url that registers RTS compatible exams
|
||||||
path("exam/json/", views.active_exams, name="active_exams"),
|
path("exam/json/", views.active_exams, name="active_exams"),
|
||||||
path("exam/json/<int:cid>/<str:passcode>", views.active_exams, name="active_exams_cid"),
|
path(
|
||||||
|
"exam/json/<int:cid>/<str:passcode>",
|
||||||
|
views.active_exams,
|
||||||
|
name="active_exams_cid",
|
||||||
|
),
|
||||||
path("exam/json/unbased", views.active_exams_unbased, name="active_exams_unbased"),
|
path("exam/json/unbased", views.active_exams_unbased, name="active_exams_unbased"),
|
||||||
path("exam/submit", views.exam_submit, name="global_exam_answers_submit"),
|
path("exam/submit", views.exam_submit, name="global_exam_answers_submit"),
|
||||||
#path('', include('generic.urls')),
|
# path('', include('generic.urls')),
|
||||||
path("feedback/<str:question_type>/<int:pk>", views.AddQuestionNote.as_view(), name="feedback_create"),
|
path(
|
||||||
path("feedback/note/<int:pk>/complete", views.feedback_mark_complete, name="feedback_mark_complete"),
|
"feedback/<str:question_type>/<int:pk>",
|
||||||
path("feedback/note/<int:pk>/delete", views.feedback_note_delete, name="feedback_note_delete"),
|
views.AddQuestionNote.as_view(),
|
||||||
#path("feedback/", views.AddQuestionNote.as_view(), name="feedback"),
|
name="feedback_create",
|
||||||
path("feedback/answer", views.answer_suggestion_submit, name="answer_suggestion_submit"),
|
),
|
||||||
|
path(
|
||||||
|
"feedback/note/<int:pk>/complete",
|
||||||
|
views.feedback_mark_complete,
|
||||||
|
name="feedback_mark_complete",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"feedback/note/<int:pk>/delete",
|
||||||
|
views.feedback_note_delete,
|
||||||
|
name="feedback_note_delete",
|
||||||
|
),
|
||||||
|
# path("feedback/", views.AddQuestionNote.as_view(), name="feedback"),
|
||||||
|
path(
|
||||||
|
"feedback/answer",
|
||||||
|
views.answer_suggestion_submit,
|
||||||
|
name="answer_suggestion_submit",
|
||||||
|
),
|
||||||
path("feedback/answer_submit", views.answer_submit, name="answer_submit"),
|
path("feedback/answer_submit", views.answer_submit, name="answer_submit"),
|
||||||
path("feedback/answer/confirm", views.answer_suggestion_confirm, name="answer_suggestion_confirm"),
|
path(
|
||||||
|
"feedback/answer/confirm",
|
||||||
|
views.answer_suggestion_confirm,
|
||||||
|
name="answer_suggestion_confirm",
|
||||||
|
),
|
||||||
path("feedback/view", views.view_feedback, name="view_feedback"),
|
path("feedback/view", views.view_feedback, name="view_feedback"),
|
||||||
|
path("api/", include(router.urls)),
|
||||||
|
path("api-auth/", include("rest_framework.urls")),
|
||||||
path('api/', include(router.urls)),
|
path("tinymce/", include("tinymce.urls")),
|
||||||
path('api-auth/', include('rest_framework.urls')),
|
# path('select2/', include('select2.urls')),
|
||||||
|
|
||||||
path('tinymce/', include('tinymce.urls')),
|
|
||||||
|
|
||||||
#path('select2/', include('select2.urls')),
|
|
||||||
|
|
||||||
]
|
]
|
||||||
|
|
||||||
#handler400 = 'my_app.views.bad_request'
|
# handler400 = 'my_app.views.bad_request'
|
||||||
handler403 = 'rad.views.page_forbidden'
|
handler403 = "rad.views.page_forbidden"
|
||||||
handler404 = 'rad.views.page_not_found'
|
handler404 = "rad.views.page_not_found"
|
||||||
handler500 = 'rad.views.server_error'
|
handler500 = "rad.views.server_error"
|
||||||
#handler500 = 'my_app.views.server_error'
|
# handler500 = 'my_app.views.server_error'
|
||||||
|
|
||||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||||
|
|
||||||
if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
urlpatterns += static(settings.MEDIA_URL,
|
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||||
document_root=settings.MEDIA_ROOT)
|
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
||||||
urlpatterns += static(settings.STATIC_URL,
|
urlpatterns += static("/rts/", document_root="rts")
|
||||||
document_root=settings.STATIC_ROOT)
|
|
||||||
urlpatterns += static("/rts/",
|
|
||||||
document_root="rts")
|
|
||||||
import debug_toolbar
|
import debug_toolbar
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
|||||||
+3
-3
@@ -493,7 +493,7 @@ def view_feedback(request):
|
|||||||
def page_not_found(request, exception):
|
def page_not_found(request, exception):
|
||||||
response = render(request, "404.html", {})
|
response = render(request, "404.html", {})
|
||||||
|
|
||||||
# response.status_code = 404
|
response.status_code = 404
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@@ -501,7 +501,7 @@ def page_not_found(request, exception):
|
|||||||
def page_forbidden(request, exception):
|
def page_forbidden(request, exception):
|
||||||
response = render(request, "403.html", {})
|
response = render(request, "403.html", {})
|
||||||
|
|
||||||
# response.status_code = 404
|
response.status_code = 403
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@@ -509,7 +509,7 @@ def page_forbidden(request, exception):
|
|||||||
def server_error(request):
|
def server_error(request):
|
||||||
response = render(request, "500.html", {})
|
response = render(request, "500.html", {})
|
||||||
|
|
||||||
# response.status_code = 404
|
response.status_code = 500
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 3.2.13 on 2022-06-20 21:39
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('rapids', '0058_auto_20220521_1020'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='ciduseranswer',
|
||||||
|
name='callstate',
|
||||||
|
field=models.CharField(blank=True, choices=[('C', 'Correct Call'), ('O', 'Overcall'), ('U', 'Undercall'), ('W', 'Incorrect Call'), ('P', 'Partial Call')], max_length=1, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 3.2.13 on 2022-06-26 15:12
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('rapids', '0059_ciduseranswer_callstate'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='exam',
|
||||||
|
name='exam_results_emailed',
|
||||||
|
field=models.DateTimeField(default=None, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
+28
-1
@@ -775,6 +775,17 @@ class CidUserAnswer(models.Model):
|
|||||||
blank=True,
|
blank=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
class CallStateOptions(models.TextChoices):
|
||||||
|
CORRECT = "C", _("Correct Call")
|
||||||
|
OVERCALL = "O", _("Overcall")
|
||||||
|
UNDERCALL = "U", _("Undercall")
|
||||||
|
INCORRECTCALL = "W", _("Incorrect Call")
|
||||||
|
PARTIALCALL = "P", _("Partial Call")
|
||||||
|
|
||||||
|
callstate = models.CharField(
|
||||||
|
max_length=1, choices=CallStateOptions.choices, blank=True, null=True
|
||||||
|
)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
|
||||||
name = self.cid
|
name = self.cid
|
||||||
@@ -823,6 +834,9 @@ class CidUserAnswer(models.Model):
|
|||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse("rapids:user_answer_view", kwargs={"pk": self.pk})
|
return reverse("rapids:user_answer_view", kwargs={"pk": self.pk})
|
||||||
|
|
||||||
|
def get_answer_callstate(self):
|
||||||
|
return self.callstate.label
|
||||||
|
|
||||||
def get_answer_score(self, cached=True):
|
def get_answer_score(self, cached=True):
|
||||||
if cached and self.score:
|
if cached and self.score:
|
||||||
print("CACHED")
|
print("CACHED")
|
||||||
@@ -840,10 +854,20 @@ class CidUserAnswer(models.Model):
|
|||||||
|
|
||||||
# First step we check that the normal/abnormal states match
|
# First step we check that the normal/abnormal states match
|
||||||
if q.normal != self.normal:
|
if q.normal != self.normal:
|
||||||
|
if q.normal:
|
||||||
|
self.callstate = CidUserAnswer.CallStateOptions.OVERCALL
|
||||||
|
else:
|
||||||
|
self.callstate = CidUserAnswer.CallStateOptions.UNDERCALL
|
||||||
|
|
||||||
|
self.score = Answer.MarkOptions.INCORRECT
|
||||||
|
self.save()
|
||||||
# If they don't match the answer is wrong (score is 0)
|
# If they don't match the answer is wrong (score is 0)
|
||||||
return 0
|
return 0
|
||||||
# If both are normal full marks
|
# If both are normal full marks
|
||||||
elif q.normal and self.normal:
|
elif q.normal and self.normal:
|
||||||
|
self.callstate = CidUserAnswer.CallStateOptions.CORRECT
|
||||||
|
self.score = Answer.MarkOptions.CORRECT
|
||||||
|
self.save()
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
# Then compare answer strings (as per anatomy questions)
|
# Then compare answer strings (as per anatomy questions)
|
||||||
@@ -864,13 +888,16 @@ class CidUserAnswer(models.Model):
|
|||||||
mark = "unmarked"
|
mark = "unmarked"
|
||||||
if marked_ans is not None:
|
if marked_ans is not None:
|
||||||
self.score = marked_ans.status
|
self.score = marked_ans.status
|
||||||
self.save()
|
|
||||||
|
|
||||||
if marked_ans.status == Answer.MarkOptions.CORRECT:
|
if marked_ans.status == Answer.MarkOptions.CORRECT:
|
||||||
|
self.callstate = CidUserAnswer.CallStateOptions.CORRECT
|
||||||
mark = 2
|
mark = 2
|
||||||
elif marked_ans.status == Answer.MarkOptions.HALF_MARK:
|
elif marked_ans.status == Answer.MarkOptions.HALF_MARK:
|
||||||
|
self.callstate = CidUserAnswer.CallStateOptions.PARTIALCALL
|
||||||
mark = 1
|
mark = 1
|
||||||
elif marked_ans.status == Answer.MarkOptions.INCORRECT:
|
elif marked_ans.status == Answer.MarkOptions.INCORRECT:
|
||||||
|
self.callstate = CidUserAnswer.CallStateOptions.INCORRECTCALL
|
||||||
mark = 0
|
mark = 0
|
||||||
|
self.save()
|
||||||
|
|
||||||
return mark
|
return mark
|
||||||
|
|||||||
@@ -34,9 +34,9 @@
|
|||||||
{% for cid in cids %}
|
{% for cid in cids %}
|
||||||
<tr>
|
<tr>
|
||||||
{% if cid|slice:":1" == "u" %}
|
{% if cid|slice:":1" == "u" %}
|
||||||
<td>{{cid}}</td>
|
<td>{{cid}}</td>
|
||||||
{% else %}
|
{% else %}
|
||||||
<td><a href="{% url 'cid_scores_admin' cid %}">{{cid}}</a></td>
|
<td><a href="{% url 'cid_scores_admin' cid %}">{{cid}}</a></td>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<td>{{user_scores|get_item:cid}}</td>
|
<td>{{user_scores|get_item:cid}}</td>
|
||||||
<td>{{user_scores_normalised|get_item:cid}}</td>
|
<td>{{user_scores_normalised|get_item:cid}}</td>
|
||||||
@@ -68,7 +68,7 @@
|
|||||||
</td>
|
</td>
|
||||||
{% for cid in cids %}
|
{% for cid in cids %}
|
||||||
{% with by_question|get_item:question|get_item:cid as ans_score %}
|
{% with by_question|get_item:question|get_item:cid as ans_score %}
|
||||||
<td class="user-answer-score-{{ans_score.1}}" title="answer score: {{ans_score.1}}">{{ans_score.0}}</td>
|
<td class="user-answer-score-{{ans_score.1}}" title="answer score: {{ans_score.1}} [{{ans_score.2}}]">{{ans_score.0}}</td>
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -81,6 +81,41 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
Call breakdown
|
||||||
|
<ul>
|
||||||
|
{% for user, breakdown in user_answers_callstates.items %}
|
||||||
|
<li>
|
||||||
|
{{user}} - {{breakdown}}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Email results</summary>
|
||||||
|
User results emailed: {{exam.exam_results_emailed|default:"Never"}}<br/>
|
||||||
|
<button id="email-results-button" title="Email results to users and their supervisors">Email user results</button>
|
||||||
|
<button id="email-results-check-button" title="Check the status of emailed results">Check email status</button>
|
||||||
|
</details>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
$(document).ready(() => {
|
||||||
|
$("#email-results-button").click((evt) => {
|
||||||
|
if (confirm("This will email results, please make sure scores have been refreshed before continuing")) {
|
||||||
|
window.location = "{% url 'rapids:exam_report_email' exam_id=exam.pk %}";
|
||||||
|
|
||||||
|
}
|
||||||
|
evt.preventDefault();
|
||||||
|
|
||||||
|
})
|
||||||
|
$("#email-results-check-button").click((evt) => {
|
||||||
|
window.location = "{% url 'rapids:exam_report_email_status' exam_id=exam.pk %}";
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|||||||
@@ -36,11 +36,11 @@ def create_superuser(db, django_user_model):
|
|||||||
@pytest.fixture(params=EXAM_VIEWS)
|
@pytest.fixture(params=EXAM_VIEWS)
|
||||||
def set_up_exams(request, django_user_model, create_superuser):
|
def set_up_exams(request, django_user_model, create_superuser):
|
||||||
basic_user = django_user_model.objects.create_user(
|
basic_user = django_user_model.objects.create_user(
|
||||||
"user1", "user@user.net", "pass2"
|
"basicuser", "user@user.net", "pass2"
|
||||||
)
|
)
|
||||||
|
|
||||||
e1 = request.param.Exam.objects.create(name="test exam", exam_mode=True)
|
e1 = request.param.Exam.objects.create(name="test exam", exam_mode=True)
|
||||||
e = request.param.Exam.objects.create(name="test exam 2", exam_mode=True)
|
e = request.param.Exam.objects.create(name="test exam basic user", exam_mode=True)
|
||||||
e.author.add(basic_user)
|
e.author.add(basic_user)
|
||||||
return request.param
|
return request.param
|
||||||
|
|
||||||
@@ -64,12 +64,31 @@ def test_index2(client, set_up_exams):
|
|||||||
== "2 exams found."
|
== "2 exams found."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_overview(client, set_up_exams):
|
||||||
|
client.login(username="admin", password="adminpassword")
|
||||||
|
|
||||||
|
e1 = set_up_exams.Exam.objects.get(name="test exam").pk
|
||||||
|
e2 = set_up_exams.Exam.objects.get(name="test exam basic user").pk
|
||||||
|
|
||||||
|
response = client.get(reverse(f"{set_up_exams.app_name}:exam_overview", args=[e1]))
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
response = client.get(reverse(f"{set_up_exams.app_name}:exam_overview", args=[e2]))
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
client.login(username="basicuser", password="pass2")
|
||||||
|
response = client.get(reverse(f"{set_up_exams.app_name}:exam_overview", args=[e1]))
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
response = client.get(reverse(f"{set_up_exams.app_name}:exam_overview", args=[e2]))
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("exam_views", EXAM_VIEWS)
|
@pytest.mark.parametrize("exam_views", EXAM_VIEWS)
|
||||||
def test_index(client, create_superuser, exam_views, django_user_model):
|
def test_index(client, create_superuser, exam_views, django_user_model):
|
||||||
|
|
||||||
basic_user = django_user_model.objects.create_user(
|
basic_user = django_user_model.objects.create_user(
|
||||||
"user1", "user@user.net", "pass2"
|
"basicuser", "user@user.net", "pass2"
|
||||||
)
|
)
|
||||||
|
|
||||||
e1 = exam_views.Exam.objects.create(name="test exam", exam_mode=True)
|
e1 = exam_views.Exam.objects.create(name="test exam", exam_mode=True)
|
||||||
|
|||||||
@@ -72,6 +72,10 @@ urlpatterns = [
|
|||||||
name="exam_question_detail",
|
name="exam_question_detail",
|
||||||
),
|
),
|
||||||
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:exam_id>/report/email", views.GenericExamViews.exam_report_email, name="exam_report_email"),
|
||||||
|
path("exam/<int:exam_id>/report/email/status", views.GenericExamViews.exam_report_email_status, name="exam_report_email_status"),
|
||||||
|
path("exam/<int:exam_id>/report/<int:user_id>", views.GenericExamViews.exam_user_report, name="exam_user_report"),
|
||||||
|
path("exam/<int:exam_id>/report/<int:user_id>/email", views.GenericExamViews.exam_user_report_email, name="exam_user_report_email"),
|
||||||
path("exam/<int:pk>/authors", views.ExamAuthorUpdate.as_view(), name="exam_authors"),
|
path("exam/<int:pk>/authors", views.ExamAuthorUpdate.as_view(), name="exam_authors"),
|
||||||
path(
|
path(
|
||||||
"exam/<int:pk>/json_edit",
|
"exam/<int:pk>/json_edit",
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 3.2.13 on 2022-06-26 15:12
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('sbas', '0014_auto_20220521_2135'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='exam',
|
||||||
|
name='exam_results_emailed',
|
||||||
|
field=models.DateTimeField(default=None, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user