diff --git a/anatomy/migrations/0017_exam_end_date_exam_restrict_to_dates_exam_start_date.py b/anatomy/migrations/0017_exam_end_date_exam_restrict_to_dates_exam_start_date.py new file mode 100644 index 00000000..62b01cf4 --- /dev/null +++ b/anatomy/migrations/0017_exam_end_date_exam_restrict_to_dates_exam_start_date.py @@ -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), + ), + ] diff --git a/anatomy/views.py b/anatomy/views.py index f80cade5..136191b6 100644 --- a/anatomy/views.py +++ b/anatomy/views.py @@ -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() diff --git a/atlas/migrations/0051_casecollection_end_date_and_more.py b/atlas/migrations/0051_casecollection_end_date_and_more.py new file mode 100644 index 00000000..49d831c1 --- /dev/null +++ b/atlas/migrations/0051_casecollection_end_date_and_more.py @@ -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), + ), + ] diff --git a/atlas/views.py b/atlas/views.py index e1d9b4d6..e88c0035 100755 --- a/atlas/views.py +++ b/atlas/views.py @@ -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) diff --git a/generic/forms.py b/generic/forms.py index 69cdb7d7..bbc2e972 100755 --- a/generic/forms.py +++ b/generic/forms.py @@ -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 diff --git a/generic/models.py b/generic/models.py index 89545fd3..25504a80 100644 --- a/generic/models.py +++ b/generic/models.py @@ -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 = "" diff --git a/generic/views.py b/generic/views.py index 260c46e6..b1ebb901 100644 --- a/generic/views.py +++ b/generic/views.py @@ -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: diff --git a/longs/migrations/0023_exam_end_date_exam_restrict_to_dates_exam_start_date.py b/longs/migrations/0023_exam_end_date_exam_restrict_to_dates_exam_start_date.py new file mode 100644 index 00000000..0a21f723 --- /dev/null +++ b/longs/migrations/0023_exam_end_date_exam_restrict_to_dates_exam_start_date.py @@ -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), + ), + ] diff --git a/physics/migrations/0010_exam_end_date_exam_restrict_to_dates_exam_start_date.py b/physics/migrations/0010_exam_end_date_exam_restrict_to_dates_exam_start_date.py new file mode 100644 index 00000000..0458b9ed --- /dev/null +++ b/physics/migrations/0010_exam_end_date_exam_restrict_to_dates_exam_start_date.py @@ -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), + ), + ] diff --git a/physics/views.py b/physics/views.py index f453c615..13dfcdbb 100644 --- a/physics/views.py +++ b/physics/views.py @@ -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) diff --git a/rapids/migrations/0009_exam_end_date_exam_restrict_to_dates_exam_start_date.py b/rapids/migrations/0009_exam_end_date_exam_restrict_to_dates_exam_start_date.py new file mode 100644 index 00000000..4d7cb326 --- /dev/null +++ b/rapids/migrations/0009_exam_end_date_exam_restrict_to_dates_exam_start_date.py @@ -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), + ), + ] diff --git a/rapids/tests/test_rapids_exams.py b/rapids/tests/test_rapids_exams.py index 977688ed..493aee5b 100644 --- a/rapids/tests/test_rapids_exams.py +++ b/rapids/tests/test_rapids_exams.py @@ -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) \ No newline at end of file diff --git a/sbas/migrations/0010_exam_end_date_exam_restrict_to_dates_exam_start_date.py b/sbas/migrations/0010_exam_end_date_exam_restrict_to_dates_exam_start_date.py new file mode 100644 index 00000000..b8bbc912 --- /dev/null +++ b/sbas/migrations/0010_exam_end_date_exam_restrict_to_dates_exam_start_date.py @@ -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), + ), + ] diff --git a/sbas/views.py b/sbas/views.py index cee02d3b..3110e044 100644 --- a/sbas/views.py +++ b/sbas/views.py @@ -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)