start adding exam dates

This commit is contained in:
Ross
2024-05-18 08:39:45 +01:00
parent f37b3312e5
commit 4367b88057
14 changed files with 322 additions and 32 deletions
@@ -0,0 +1,28 @@
# Generated by Django 5.0.2 on 2024-05-13 10:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('anatomy', '0016_alter_exam_authors_only'),
]
operations = [
migrations.AddField(
model_name='exam',
name='end_date',
field=models.DateTimeField(help_text='Date (and time) the exam/collection ends', null=True),
),
migrations.AddField(
model_name='exam',
name='restrict_to_dates',
field=models.BooleanField(default=False, help_text='If the exam/collection should only be taken between the start and end date times'),
),
migrations.AddField(
model_name='exam',
name='start_date',
field=models.DateTimeField(help_text='Date (and time) the exam/collection starts', null=True),
),
]
+1 -1
View File
@@ -413,7 +413,7 @@ def exam_scores_cid_user(request, pk, cid, passcode):
if not exam.exam_mode: if not exam.exam_mode:
raise Http404("Packet not in exam mode") raise Http404("Packet not in exam mode")
if not exam.check_cid_user(cid, passcode, request): if not exam.check_cid_user(cid, passcode, request.user):
raise Http404("Error accessing exam") raise Http404("Error accessing exam")
questions = exam.exam_questions.all() questions = exam.exam_questions.all()
@@ -0,0 +1,28 @@
# Generated by Django 5.0.2 on 2024-05-13 10:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0050_rename_questions_casedetail_question_schema'),
]
operations = [
migrations.AddField(
model_name='casecollection',
name='end_date',
field=models.DateTimeField(help_text='Date (and time) the exam/collection ends', null=True),
),
migrations.AddField(
model_name='casecollection',
name='restrict_to_dates',
field=models.BooleanField(default=False, help_text='If the exam/collection should only be taken between the start and end date times'),
),
migrations.AddField(
model_name='casecollection',
name='start_date',
field=models.DateTimeField(help_text='Date (and time) the exam/collection starts', null=True),
),
]
+2 -2
View File
@@ -1736,7 +1736,7 @@ def collection_take_overview(
""" """
collection = get_object_or_404(CaseCollection, pk=pk) collection = get_object_or_404(CaseCollection, pk=pk)
collection.check_user_can_take(cid, passcode, request) collection.check_user_can_take(cid, passcode, request.user)
cid_user_exam = collection.get_or_create_cid_user_exam( cid_user_exam = collection.get_or_create_cid_user_exam(
cid=cid, user_user=request.user cid=cid, user_user=request.user
@@ -1819,7 +1819,7 @@ def collection_case_view_take(
form = None form = None
answer: None | CidReportAnswer | UserReportAnswer = None answer: None | CidReportAnswer | UserReportAnswer = None
collection.check_user_can_take(cid, passcode, request) collection.check_user_can_take(cid, passcode, request.user)
case, case_count = collection.get_case_by_index(case_number, case_count=True) case, case_count = collection.get_case_by_index(case_number, case_count=True)
+31 -3
View File
@@ -8,6 +8,9 @@ from django.forms import (
HiddenInput, HiddenInput,
EmailField, EmailField,
IntegerField, IntegerField,
DateField,
SplitDateTimeField,
SplitDateTimeWidget,
) )
from django.forms import inlineformset_factory from django.forms import inlineformset_factory
from atlas.models import CaseCollection from atlas.models import CaseCollection
@@ -79,6 +82,24 @@ class ExamFormMixin:
required=False, required=False,
queryset=user_user_group_queryset, queryset=user_user_group_queryset,
) )
self.fields["start_date"] = SplitDateTimeField(
widget=SplitDateTimeWidget(
date_attrs={"type": "date", "class": "datepicker"},
time_attrs={"type": "time", "class": "timepicker"},
date_format="%Y-%m-%d",
time_format="%H:%M",
),
required=False,
)
self.fields["end_date"] = SplitDateTimeField(
widget=SplitDateTimeWidget(
date_attrs={"type": "date", "class": "datepicker"},
time_attrs={"type": "time", "class": "timepicker"},
date_format="%Y-%m-%d",
time_format="%H:%M",
),
required=False,
)
class Meta: class Meta:
fields = [ fields = [
@@ -88,6 +109,9 @@ class ExamFormMixin:
"authors_only", "authors_only",
"exam_mode", "exam_mode",
"include_history", "include_history",
"start_date",
"end_date",
"restrict_to_dates",
"active", "active",
"publish_results", "publish_results",
"archive", "archive",
@@ -96,6 +120,11 @@ class ExamFormMixin:
# "author", # "author",
] ]
# widgets = {
# "start_date": TextInput(attrs={"type": "date"}),
# "end_date": TextInput(attrs={"type": "date"}),
# }
def save(self, commit=True): def save(self, commit=True):
instance = ModelForm.save(self, False) instance = ModelForm.save(self, False)
instance.save() instance.save()
@@ -588,9 +617,7 @@ class ExamCollectionForm(ModelForm):
self.fields[reverse_prop] = ModelMultipleChoiceField( self.fields[reverse_prop] = ModelMultipleChoiceField(
required=False, required=False,
queryset=exam_obj.objects.filter(archive=False), queryset=exam_obj.objects.filter(archive=False),
widget=FilteredSelectMultiple( widget=FilteredSelectMultiple(verbose_name=name, is_stacked=False),
verbose_name=name, is_stacked=False
),
) )
def save(self, commit=True): def save(self, commit=True):
@@ -619,6 +646,7 @@ class ExamCollectionForm(ModelForm):
return instance return instance
class ExamCollectionCloneForm(ModelForm): class ExamCollectionCloneForm(ModelForm):
class Meta: class Meta:
model = ExamCollection model = ExamCollection
+55 -16
View File
@@ -2,9 +2,10 @@ from collections import defaultdict
import datetime import datetime
import json import json
import os import os
from typing import Self, Tuple from typing import Optional, Self, Tuple
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.forms import ValidationError
from django.http import Http404, HttpRequest from django.http import Http404, HttpRequest
from django.utils import timezone from django.utils import timezone
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
@@ -553,6 +554,10 @@ class ExamOrCollectionGenericBase(models.Model, AuthorMixin):
name = models.CharField(max_length=200, help_text="Name of the exam/collection") name = models.CharField(max_length=200, help_text="Name of the exam/collection")
start_date = models.DateTimeField(help_text="Date (and time) the exam/collection starts", null=True, blank=True)
end_date = models.DateTimeField(help_text="Date (and time) the exam/collection ends", null=True, blank=True)
restrict_to_dates = models.BooleanField(default=False, help_text="If the exam/collection should only be taken between the start and end date times")
active = models.BooleanField( active = models.BooleanField(
help_text="If an exam/collection should be available to take", default=False help_text="If an exam/collection should be available to take", default=False
) )
@@ -583,6 +588,22 @@ class ExamOrCollectionGenericBase(models.Model, AuthorMixin):
class Meta: class Meta:
abstract = True abstract = True
def save(self, *args, **kwargs):
self.full_clean()
return super().save(*args, **kwargs)
def clean(self, *args, **kwargs):
if self.restrict_to_dates:
if self.start_date is None:
raise ValidationError("If restrict to dates is set, a start date must be set")
if self.end_date is not None and self.end_date <= self.start_date:
print(f"{self.start_date=}")
print(f"{self.end_date=}")
raise ValidationError("End date must be after start date")
return super().clean(*args, **kwargs)
def get_app_name(self): def get_app_name(self):
raise NotImplementedError("You must implement get_app_name in the derived class") raise NotImplementedError("You must implement get_app_name in the derived class")
@@ -603,15 +624,15 @@ class ExamOrCollectionGenericBase(models.Model, AuthorMixin):
def check_logged_in_user(self, request: HttpRequest): def check_logged_in_user(self, request: HttpRequest):
"""Helper to check if the logged in user can access the exam""" """Helper to check if the logged in user can access the exam"""
return self.check_cid_user(request=request, user_id=request.user.id) return self.check_cid_user(user=request.user, user_id=request.user.id)
def check_user_can_review( def check_user_can_review(
self, cid, passcode, request: HttpRequest, active_only=False self, cid, passcode, request: HttpRequest, active_only=False
): ):
return self.check_user_can_take(cid, passcode, request, active_only) return self.check_user_can_take(cid, passcode, request.user, active_only)
def check_user_can_take( def check_user_can_take(
self, cid, passcode, request: HttpRequest, active_only=True self, cid, passcode, user = None, active_only=True
): ):
""" """
Helper to check if a user is allowed to access an exam/collection Helper to check if a user is allowed to access an exam/collection
@@ -624,33 +645,40 @@ class ExamOrCollectionGenericBase(models.Model, AuthorMixin):
Raises: Raises:
Http404: If user does not have access Http404: If user does not have access
""" """
# If we are limiting by dates check that the current date is within the range
if self.restrict_to_dates:
if self.start_date >= timezone.now() or (self.end_date is not None and self.end_date <= timezone.now()):
raise Http404("Exam not found")
# If it is not active no one can take # If it is not active no one can take
print(0) elif active_only and not self.active:
if active_only and not self.active:
raise Http404("Exam not found") raise Http404("Exam not found")
print(1)
# If candidates only check they have access # If candidates only check they have access
if self.candidates_only: if self.candidates_only:
if not self.check_cid_user(cid, passcode, request): if not self.check_cid_user(cid, passcode, user):
raise Http404("Error accessing exam") raise Http404("Error accessing exam")
return return
print(2)
# Otherwise check that they are a valid user. # Otherwise check that they are a valid user.
if not request.user.is_active: if user is None:
raise Http404("Error accessing exam") raise Http404("Error accessing exam")
if not user.is_active:
raise Http404("Error accessing exam")
return
def check_cid_user( def check_cid_user(
self, self,
cid: int | None = None, cid: int | None = None,
passcode: str | None = None, passcode: str | None = None,
request: HttpRequest | None = None, user: User | None = None,
#request: HttpRequest | None = None,
user_id: int | None = None, user_id: int | None = None,
allow_authors: bool = True, allow_authors: bool = True,
): ):
print(f"Check cid user: {cid=}, {passcode=}, exam_id={self.pk}") print(f"Check cid user: {cid=}, {passcode=}, exam_id={self.pk}")
if request is not None and request.user.is_superuser: if user is not None and user.is_superuser:
return True return True
if user_id is not None: if user_id is not None:
@@ -662,7 +690,10 @@ class ExamOrCollectionGenericBase(models.Model, AuthorMixin):
return False return False
if cid is None and user_id is None: if cid is None and user_id is None:
user_id = request.user.id if user is not None:
user_id = user.id
else:
return False
# Start by checking if the logged in user can access # Start by checking if the logged in user can access
if user_id is not None: if user_id is not None:
@@ -739,7 +770,7 @@ class ExamOrCollectionGenericBase(models.Model, AuthorMixin):
raise NotImplementedError raise NotImplementedError
def get_cid_user_exams( def get_cid_user_exams(
self, cid_user: "CidUser" = None, user_user: User = None self, cid_user: Optional["CidUser"] = None, user_user: User = None
) -> "CidUserExam": ) -> "CidUserExam":
content_type = ContentType.objects.get_for_model(self) content_type = ContentType.objects.get_for_model(self)
if cid_user is None and user_user is None: if cid_user is None and user_user is None:
@@ -833,16 +864,22 @@ class ExamBase(ExamOrCollectionGenericBase):
stats_graph = models.TextField(default=0) stats_graph = models.TextField(default=0)
user_scores = models.JSONField(default=dict) user_scores = models.JSONField(default=dict, blank=True)
exam_results_emailed = models.DateTimeField(default=None, null=True) exam_results_emailed = models.DateTimeField(default=None, null=True, blank=True)
class Meta: class Meta:
abstract = True abstract = True
def save(self, *args, recreate_json=True, **kwargs): def save(self, *args, recreate_json=True, **kwargs):
self.recreate_json = recreate_json self.recreate_json = recreate_json
super().save(*args, **kwargs) super().save(*args, **kwargs)
#def clean(self, *args, **kwargs):
# if self.user_scores is None:
# self.user_scores = {}
# return super().clean(*args, **kwargs)
def get_questions(self): def get_questions(self):
"""Returns a list of questions in the exam in the correct order""" """Returns a list of questions in the exam in the correct order"""
@@ -900,6 +937,8 @@ class ExamBase(ExamOrCollectionGenericBase):
def get_time_limit(self): def get_time_limit(self):
"""Returns a human readable time limit""" """Returns a human readable time limit"""
if self.time_limit is None:
return "No time limit set"
h, m, s = str(datetime.timedelta(seconds=self.time_limit)).split(":") h, m, s = str(datetime.timedelta(seconds=self.time_limit)).split(":")
time_limit = "" time_limit = ""
+6 -6
View File
@@ -1487,7 +1487,7 @@ class ExamViews(View, LoginRequiredMixin):
f"{exam.app_name=}, {exam.name=}, {cid=}, {passcode=}, {request.user=}" f"{exam.app_name=}, {exam.name=}, {cid=}, {passcode=}, {request.user=}"
) )
if exam.exam_mode and not exam.check_cid_user( if exam.exam_mode and not exam.check_cid_user(
cid, passcode, request=request, user_id=request.user.pk cid, passcode, user=request.user, user_id=request.user.pk
): ):
print(exam.name, "fail") print(exam.name, "fail")
continue continue
@@ -1736,7 +1736,7 @@ class ExamViews(View, LoginRequiredMixin):
user_id = None user_id = None
else: else:
user_id = request.user.pk user_id = request.user.pk
if exam.exam_mode and not exam.check_cid_user(cid, passcode, request, user_id): if exam.exam_mode and not exam.check_cid_user(cid, passcode, request.user, user_id):
raise Http404("No available exam") raise Http404("No available exam")
# exam_json_cache = cache.get("{}_exam_json_{}".format(self.app_name, pk)) # exam_json_cache = cache.get("{}_exam_json_{}".format(self.app_name, pk))
@@ -1811,7 +1811,7 @@ class ExamViews(View, LoginRequiredMixin):
user_id = None user_id = None
else: else:
user_id = request.user.pk user_id = request.user.pk
if not exam.check_cid_user(cid, passcode, request, user_id): if not exam.check_cid_user(cid, passcode, request.user, user_id):
raise Http404("No available exam") raise Http404("No available exam")
time = timezone.now() time = timezone.now()
@@ -1850,7 +1850,7 @@ class ExamViews(View, LoginRequiredMixin):
user_id = None user_id = None
else: else:
user_id = request.user.pk user_id = request.user.pk
if not exam.check_cid_user(cid, passcode, request, user_id): if not exam.check_cid_user(cid, passcode, request.user, user_id):
raise Http404("No available exam") raise Http404("No available exam")
if request.GET.get("_"): if request.GET.get("_"):
@@ -1883,7 +1883,7 @@ class ExamViews(View, LoginRequiredMixin):
print("CID", cid) print("CID", cid)
if not exam.check_cid_user(cid, passcode, request, user_id): if not exam.check_cid_user(cid, passcode, request.user, user_id):
raise Http404("No available exam") raise Http404("No available exam")
return redirect("{}:question_json_unbased".format(self.app_name), pk=sk) return redirect("{}:question_json_unbased".format(self.app_name), pk=sk)
@@ -1921,7 +1921,7 @@ class ExamViews(View, LoginRequiredMixin):
if not exam.exam_mode: if not exam.exam_mode:
raise Http404("Packet not in exam mode") raise Http404("Packet not in exam mode")
if not exam.check_cid_user(cid, passcode, request): if not exam.check_cid_user(cid, passcode, request.user):
raise Http404("Error accessing exam") raise Http404("Error accessing exam")
if user is not None: if user is not None:
@@ -0,0 +1,28 @@
# Generated by Django 5.0.2 on 2024-05-13 10:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('longs', '0022_alter_exam_authors_only'),
]
operations = [
migrations.AddField(
model_name='exam',
name='end_date',
field=models.DateTimeField(help_text='Date (and time) the exam/collection ends', null=True),
),
migrations.AddField(
model_name='exam',
name='restrict_to_dates',
field=models.BooleanField(default=False, help_text='If the exam/collection should only be taken between the start and end date times'),
),
migrations.AddField(
model_name='exam',
name='start_date',
field=models.DateTimeField(help_text='Date (and time) the exam/collection starts', null=True),
),
]
@@ -0,0 +1,28 @@
# Generated by Django 5.0.2 on 2024-05-13 10:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('physics', '0009_alter_exam_authors_only'),
]
operations = [
migrations.AddField(
model_name='exam',
name='end_date',
field=models.DateTimeField(help_text='Date (and time) the exam/collection ends', null=True),
),
migrations.AddField(
model_name='exam',
name='restrict_to_dates',
field=models.BooleanField(default=False, help_text='If the exam/collection should only be taken between the start and end date times'),
),
migrations.AddField(
model_name='exam',
name='start_date',
field=models.DateTimeField(help_text='Date (and time) the exam/collection starts', null=True),
),
]
+2 -2
View File
@@ -139,7 +139,7 @@ def exam_start(request, pk):
def exam_take_overview(request, pk, cid=None, passcode=None): def exam_take_overview(request, pk, cid=None, passcode=None):
exam = get_object_or_404(Exam, pk=pk) exam = get_object_or_404(Exam, pk=pk)
exam.check_user_can_take(cid, passcode, request) exam.check_user_can_take(cid, passcode, request.user)
questions = exam.exam_questions.all() questions = exam.exam_questions.all()
@@ -185,7 +185,7 @@ def exam_take(request, pk: int, sk: int, cid: str| None = None, passcode: str| N
if not exam.active: if not exam.active:
return exam_inactive(request, context={"exam": exam}) return exam_inactive(request, context={"exam": exam})
exam.check_user_can_take(cid, passcode, request) exam.check_user_can_take(cid, passcode, request.user)
cid_user_exam = exam.get_or_create_cid_user_exam(cid=cid, user_user=request.user) cid_user_exam = exam.get_or_create_cid_user_exam(cid=cid, user_user=request.user)
@@ -0,0 +1,28 @@
# Generated by Django 5.0.2 on 2024-05-13 10:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('rapids', '0008_alter_exam_authors_only'),
]
operations = [
migrations.AddField(
model_name='exam',
name='end_date',
field=models.DateTimeField(help_text='Date (and time) the exam/collection ends', null=True),
),
migrations.AddField(
model_name='exam',
name='restrict_to_dates',
field=models.BooleanField(default=False, help_text='If the exam/collection should only be taken between the start and end date times'),
),
migrations.AddField(
model_name='exam',
name='start_date',
field=models.DateTimeField(help_text='Date (and time) the exam/collection starts', null=True),
),
]
+55
View File
@@ -1,7 +1,12 @@
import json import json
import os import os
from re import A from re import A
import time
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError
from django.http import Http404
from django.utils import timezone
import pytest import pytest
import tempfile import tempfile
@@ -508,3 +513,53 @@ def test_exams(db, client):
generated_questions.pop(-1).delete() generated_questions.pop(-1).delete()
users_tested = users_tested + 1 users_tested = users_tested + 1
def test_exams_with_dates(db, client):
create_cid_user_and_groups(db)
exam = create_exam(db)
active_exams = client.get(reverse(f"{APP_NAME}:active_exams"))
assert active_exams.status_code == 200
assert len(json.loads(active_exams.content)["exams"]) == 0
cid_user_1000 = CidUser.objects.get(cid=1000)
user1 = User.objects.get(username="user1")
exam.check_user_can_take(None, None, user1)
exam.check_user_can_take(cid_user_1000.cid, cid_user_1000.passcode)
exam.active = False
exam.save()
with pytest.raises(Http404):
exam.check_user_can_take(None, None, user1)
with pytest.raises(Http404):
exam.check_user_can_take(cid_user_1000.cid, cid_user_1000.passcode)
with pytest.raises(ValidationError):
exam.restrict_to_dates = True
exam.save()
with pytest.raises(ValidationError):
exam.restrict_to_dates = True
exam.end_date = timezone.now()
exam.start_date = timezone.now() + timezone.timedelta(days=1)
exam.save()
exam.start_date = timezone.now()
exam.end_date = None # The previously set value seems to remain...
exam.restrict_to_dates = True
exam.save()
exam.check_user_can_take(None, None, user1)
exam.check_user_can_take(cid_user_1000.cid, cid_user_1000.passcode)
exam.end_date = timezone.now()
exam.save()
with pytest.raises(Http404):
exam.check_user_can_take(None, None, user1)
with pytest.raises(Http404):
exam.check_user_can_take(cid_user_1000.cid, cid_user_1000.passcode)
@@ -0,0 +1,28 @@
# Generated by Django 5.0.2 on 2024-05-13 10:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('sbas', '0009_alter_exam_authors_only'),
]
operations = [
migrations.AddField(
model_name='exam',
name='end_date',
field=models.DateTimeField(help_text='Date (and time) the exam/collection ends', null=True),
),
migrations.AddField(
model_name='exam',
name='restrict_to_dates',
field=models.BooleanField(default=False, help_text='If the exam/collection should only be taken between the start and end date times'),
),
migrations.AddField(
model_name='exam',
name='start_date',
field=models.DateTimeField(help_text='Date (and time) the exam/collection starts', null=True),
),
]
+2 -2
View File
@@ -119,7 +119,7 @@ def exam_take_overview(request, pk, cid=None, passcode=None):
if not exam.active: if not exam.active:
return exam_inactive(request, context={"exam": exam}) return exam_inactive(request, context={"exam": exam})
exam.check_user_can_take(cid, passcode, request) exam.check_user_can_take(cid, passcode, request.user)
questions = exam.exam_questions.all() questions = exam.exam_questions.all()
@@ -164,7 +164,7 @@ def exam_take(request, pk: int, sk: int, cid: int = None, passcode: str = None):
if not exam.active: if not exam.active:
return exam_inactive(request, context={"exam": exam}) return exam_inactive(request, context={"exam": exam})
exam.check_user_can_take(cid, passcode, request) exam.check_user_can_take(cid, passcode, request.user)
cid_user_exam = exam.get_or_create_cid_user_exam(cid = cid, user_user=request.user) cid_user_exam = exam.get_or_create_cid_user_exam(cid = cid, user_user=request.user)