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:
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")
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.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=cid, user_user=request.user
@@ -1819,7 +1819,7 @@ def collection_case_view_take(
form = 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)
+31 -3
View File
@@ -8,6 +8,9 @@ from django.forms import (
HiddenInput,
EmailField,
IntegerField,
DateField,
SplitDateTimeField,
SplitDateTimeWidget,
)
from django.forms import inlineformset_factory
from atlas.models import CaseCollection
@@ -79,6 +82,24 @@ class ExamFormMixin:
required=False,
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:
fields = [
@@ -88,6 +109,9 @@ class ExamFormMixin:
"authors_only",
"exam_mode",
"include_history",
"start_date",
"end_date",
"restrict_to_dates",
"active",
"publish_results",
"archive",
@@ -96,6 +120,11 @@ class ExamFormMixin:
# "author",
]
# widgets = {
# "start_date": TextInput(attrs={"type": "date"}),
# "end_date": TextInput(attrs={"type": "date"}),
# }
def save(self, commit=True):
instance = ModelForm.save(self, False)
instance.save()
@@ -588,9 +617,7 @@ class ExamCollectionForm(ModelForm):
self.fields[reverse_prop] = ModelMultipleChoiceField(
required=False,
queryset=exam_obj.objects.filter(archive=False),
widget=FilteredSelectMultiple(
verbose_name=name, is_stacked=False
),
widget=FilteredSelectMultiple(verbose_name=name, is_stacked=False),
)
def save(self, commit=True):
@@ -619,6 +646,7 @@ class ExamCollectionForm(ModelForm):
return instance
class ExamCollectionCloneForm(ModelForm):
class Meta:
model = ExamCollection
+55 -16
View File
@@ -2,9 +2,10 @@ from collections import defaultdict
import datetime
import json
import os
from typing import Self, Tuple
from typing import Optional, Self, Tuple
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db import models
from django.forms import ValidationError
from django.http import Http404, HttpRequest
from django.utils import timezone
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")
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(
help_text="If an exam/collection should be available to take", default=False
)
@@ -583,6 +588,22 @@ class ExamOrCollectionGenericBase(models.Model, AuthorMixin):
class Meta:
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):
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):
"""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(
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(
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
@@ -624,33 +645,40 @@ class ExamOrCollectionGenericBase(models.Model, AuthorMixin):
Raises:
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
print(0)
if active_only and not self.active:
elif active_only and not self.active:
raise Http404("Exam not found")
print(1)
# If candidates only check they have access
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")
return
print(2)
# Otherwise check that they are a valid user.
if not request.user.is_active:
if user is None:
raise Http404("Error accessing exam")
if not user.is_active:
raise Http404("Error accessing exam")
return
def check_cid_user(
self,
cid: int | None = None,
passcode: str | None = None,
request: HttpRequest | None = None,
user: User | None = None,
#request: HttpRequest | None = None,
user_id: int | None = None,
allow_authors: bool = True,
):
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
if user_id is not None:
@@ -662,7 +690,10 @@ class ExamOrCollectionGenericBase(models.Model, AuthorMixin):
return False
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
if user_id is not None:
@@ -739,7 +770,7 @@ class ExamOrCollectionGenericBase(models.Model, AuthorMixin):
raise NotImplementedError
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":
content_type = ContentType.objects.get_for_model(self)
if cid_user is None and user_user is None:
@@ -833,16 +864,22 @@ class ExamBase(ExamOrCollectionGenericBase):
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:
abstract = True
def save(self, *args, recreate_json=True, **kwargs):
self.recreate_json = recreate_json
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):
"""Returns a list of questions in the exam in the correct order"""
@@ -900,6 +937,8 @@ class ExamBase(ExamOrCollectionGenericBase):
def get_time_limit(self):
"""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(":")
time_limit = ""
+6 -6
View File
@@ -1487,7 +1487,7 @@ class ExamViews(View, LoginRequiredMixin):
f"{exam.app_name=}, {exam.name=}, {cid=}, {passcode=}, {request.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")
continue
@@ -1736,7 +1736,7 @@ class ExamViews(View, LoginRequiredMixin):
user_id = None
else:
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")
# exam_json_cache = cache.get("{}_exam_json_{}".format(self.app_name, pk))
@@ -1811,7 +1811,7 @@ class ExamViews(View, LoginRequiredMixin):
user_id = None
else:
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")
time = timezone.now()
@@ -1850,7 +1850,7 @@ class ExamViews(View, LoginRequiredMixin):
user_id = None
else:
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")
if request.GET.get("_"):
@@ -1883,7 +1883,7 @@ class ExamViews(View, LoginRequiredMixin):
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")
return redirect("{}:question_json_unbased".format(self.app_name), pk=sk)
@@ -1921,7 +1921,7 @@ class ExamViews(View, LoginRequiredMixin):
if not exam.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")
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):
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()
@@ -185,7 +185,7 @@ def exam_take(request, pk: int, sk: int, cid: str| None = None, passcode: str| N
if not exam.active:
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)
@@ -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 os
from re import A
import time
from django.conf import settings
from django.core.exceptions import ValidationError
from django.http import Http404
from django.utils import timezone
import pytest
import tempfile
@@ -508,3 +513,53 @@ def test_exams(db, client):
generated_questions.pop(-1).delete()
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:
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()
@@ -164,7 +164,7 @@ def exam_take(request, pk: int, sk: int, cid: int = None, passcode: str = None):
if not exam.active:
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)