diff --git a/anatomy/forms.py b/anatomy/forms.py
index 2f5d64cf..6a44b3cc 100644
--- a/anatomy/forms.py
+++ b/anatomy/forms.py
@@ -10,7 +10,7 @@ from django.forms import inlineformset_factory
from django.contrib.postgres.forms import SimpleArrayField, SplitArrayField , SplitArrayWidget
-from generic.forms import ExamAuthorFormMixin, ExamFormMixin, ExamMarkerFormMixin
+from generic.forms import ExamAuthorFormMixin, ExamFormMixin, ExamGroupsFormMixin, ExamMarkerFormMixin
from .models import (
Answer,
@@ -236,3 +236,9 @@ class ExamAuthorForm(ExamAuthorFormMixin):
class ExamMarkerForm(ExamMarkerFormMixin):
class Meta(ExamMarkerFormMixin.Meta):
model = Exam
+
+
+
+class ExamGroupsForm(ExamGroupsFormMixin):
+ class Meta(ExamGroupsFormMixin.Meta):
+ model = Exam
\ No newline at end of file
diff --git a/anatomy/views.py b/anatomy/views.py
index a4171f2d..61903d88 100644
--- a/anatomy/views.py
+++ b/anatomy/views.py
@@ -33,6 +33,7 @@ from .forms import (
AnswerUpdateFormSet,
BodyPartForm,
ExamAuthorForm,
+ ExamGroupsForm,
ExamMarkerForm,
ExaminationForm,
MarkAnatomyQuestionForm,
@@ -51,7 +52,7 @@ from .models import (
# IncorrectAnswers,
)
from generic.models import CidUser, Examination
-from generic.views import AuthorRequiredMixin, ExamCloneMixin, ExamCreateBase, ExamDeleteBase, ExamUpdateBase, ExamViews, GenericViewBase
+from generic.views import AuthorRequiredMixin, ExamCloneMixin, ExamCreateBase, ExamDeleteBase, ExamGroupsUpdateBase, ExamUpdateBase, ExamViews, GenericViewBase
from reversion.views import RevisionMixin
from .decorators import user_is_author_or_anatomy_checker
@@ -849,6 +850,10 @@ class ExamMarkersUpdate(
template_name = "markers_form.html"
+class ExamGroupsUpdate(ExamGroupsUpdateBase):
+ model = Exam
+ form_class = ExamGroupsForm
+
GenericViews = GenericViewBase("anatomy", AnatomyQuestion, UserAnswer, Exam)
diff --git a/generic/forms.py b/generic/forms.py
index 33266a98..f09f278b 100755
--- a/generic/forms.py
+++ b/generic/forms.py
@@ -107,21 +107,21 @@ class ExamFormMixin:
def __init__(self, *args, user, **kwargs) -> None:
super(ModelForm, self).__init__(*args, **kwargs)
- if user.is_superuser or user.groups.filter(name="cid_user_manager").exists():
- cid_user_group_queryset = CidUserGroup.objects.filter(archive=False)
- user_user_group_queryset = UserUserGroup.objects.filter(archive=False)
- else:
- cid_user_group_queryset = CidUserGroup.objects.none()
- user_user_group_queryset = UserUserGroup.objects.none()
+ #if user.is_superuser or user.groups.filter(name="cid_user_manager").exists():
+ # cid_user_group_queryset = CidUserGroup.objects.filter(archive=False)
+ # user_user_group_queryset = UserUserGroup.objects.filter(archive=False)
+ #else:
+ # cid_user_group_queryset = CidUserGroup.objects.none()
+ # user_user_group_queryset = UserUserGroup.objects.none()
- self.fields["cid_user_groups"] = ModelMultipleChoiceField(
- required=False,
- queryset=cid_user_group_queryset,
- )
- self.fields["user_user_groups"] = ModelMultipleChoiceField(
- required=False,
- queryset=user_user_group_queryset,
- )
+ #self.fields["cid_user_groups"] = ModelMultipleChoiceField(
+ # required=False,
+ # queryset=cid_user_group_queryset,
+ #)
+ #self.fields["user_user_groups"] = ModelMultipleChoiceField(
+ # required=False,
+ # queryset=user_user_group_queryset,
+ #)
self.fields["start_date"] = SplitDateTimeFieldDefaultTime(
widget=SplitDateTimeWidget(
date_attrs={"type": "date", "class": "datepicker"},
@@ -155,8 +155,8 @@ class ExamFormMixin:
"active",
"publish_results",
"archive",
- "cid_user_groups",
- "user_user_groups",
+ #"cid_user_groups",
+ #"user_user_groups",
# "author",
]
@@ -198,6 +198,32 @@ class ExamMarkerFormMixin(ModelForm):
self.fields["markers"].required = False
+class ExamGroupsFormMixin(ModelForm):
+ class Meta:
+ #model = ExamCollection
+ fields = ("cid_user_groups", "user_user_groups")
+
+ def __init__(self, *args, user, **kwargs) -> None:
+ super(ModelForm, self).__init__(*args, **kwargs)
+
+ if user.is_superuser or user.groups.filter(name="cid_user_manager").exists():
+ cid_user_group_queryset = CidUserGroup.objects.filter(archive=False)
+ user_user_group_queryset = UserUserGroup.objects.filter(archive=False)
+ else:
+ cid_user_group_queryset = CidUserGroup.objects.none()
+ user_user_group_queryset = UserUserGroup.objects.none()
+ #cid_user_group_queryset = CidUserGroup.objects.none()
+ #user_user_group_queryset = UserUserGroup.objects.none()
+
+ self.fields["cid_user_groups"] = ModelMultipleChoiceField(
+ required=False,
+ queryset=cid_user_group_queryset,
+ )
+ self.fields["user_user_groups"] = ModelMultipleChoiceField(
+ required=False,
+ queryset=user_user_group_queryset,
+ )
+
class ExaminationForm(ModelForm):
def __init__(self, *args, **kwargs):
@@ -704,3 +730,4 @@ class ExamCollectionCloneForm(ModelForm):
class Meta:
model = ExamCollection
fields = ("name", "date")
+
diff --git a/generic/migrations/0017_cidusergroup_open_access_userusergroup_open_access_and_more.py b/generic/migrations/0017_cidusergroup_open_access_userusergroup_open_access_and_more.py
new file mode 100644
index 00000000..dc4f963f
--- /dev/null
+++ b/generic/migrations/0017_cidusergroup_open_access_userusergroup_open_access_and_more.py
@@ -0,0 +1,38 @@
+# Generated by Django 5.0.2 on 2024-08-12 09:32
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('generic', '0016_remove_examuserstatus_cid_user_and_more'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='cidusergroup',
+ name='open_access',
+ field=models.BooleanField(default=False, help_text='If the group is freely accessible to use'),
+ ),
+ migrations.AddField(
+ model_name='userusergroup',
+ name='open_access',
+ field=models.BooleanField(default=False, help_text='If the group is freely accessible to use'),
+ ),
+ migrations.AlterField(
+ model_name='cidusergroup',
+ name='archive',
+ field=models.BooleanField(default=False, help_text='Archived groups remain on the test system but are not displayed by default'),
+ ),
+ migrations.AlterField(
+ model_name='cidusergroup',
+ name='name',
+ field=models.CharField(blank=True, help_text='Name of the Group', max_length=50),
+ ),
+ migrations.AlterField(
+ model_name='userusergroup',
+ name='name',
+ field=models.CharField(blank=True, help_text='Name of the Group', max_length=50),
+ ),
+ ]
diff --git a/generic/models.py b/generic/models.py
index e55880f7..85a6d59e 100644
--- a/generic/models.py
+++ b/generic/models.py
@@ -437,13 +437,12 @@ class SeriesBase(models.Model):
if series_number is not None:
series_html = format_html(
"Series {}{} ",
- series_number
+ series_number,
)
description = ""
if self.description:
description = format_html("{} ", self.description)
-
return format_html(
"""{}{}
@@ -554,9 +553,16 @@ 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")
+ 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
@@ -595,15 +601,19 @@ class ExamOrCollectionGenericBase(models.Model, AuthorMixin):
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")
+ 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:
- raise ValidationError({"end_date" : "End date must be after start date"})
+ raise ValidationError({"end_date": "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")
+ raise NotImplementedError(
+ "You must implement get_app_name in the derived class"
+ )
def get_base_template(self):
return f"{self.get_app_name()}/exams.html"
@@ -629,9 +639,7 @@ class ExamOrCollectionGenericBase(models.Model, AuthorMixin):
):
return self.check_user_can_take(cid, passcode, request.user, active_only)
- def check_user_can_take(
- self, cid, passcode, user = None, active_only=True
- ):
+ def check_user_can_take(self, cid, passcode, user=None, active_only=True):
"""
Helper to check if a user is allowed to access an exam/collection
@@ -645,7 +653,9 @@ class ExamOrCollectionGenericBase(models.Model, AuthorMixin):
"""
# 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()):
+ 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
elif active_only and not self.active:
@@ -663,7 +673,7 @@ class ExamOrCollectionGenericBase(models.Model, AuthorMixin):
if not user.is_active:
raise Http404("Error accessing exam")
-
+
return
def check_cid_user(
@@ -671,7 +681,7 @@ class ExamOrCollectionGenericBase(models.Model, AuthorMixin):
cid: int | None = None,
passcode: str | None = None,
user: User | None = None,
- #request: HttpRequest | None = None,
+ # request: HttpRequest | None = None,
user_id: int | None = None,
allow_authors: bool = True,
):
@@ -795,9 +805,7 @@ class ExamOrCollectionGenericBase(models.Model, AuthorMixin):
"cid_user_groups",
"user_user_groups",
)
- FK_fields = (
- "examcollection",
- )
+ FK_fields = ("examcollection",)
model_dict = model_to_dict(self)
@@ -824,8 +832,6 @@ class ExamOrCollectionGenericBase(models.Model, AuthorMixin):
for item in m2m_to_update[field]:
rel.add(item)
-
-
return cloned_model
@@ -835,9 +841,9 @@ class ExamBase(ExamOrCollectionGenericBase):
default=False,
)
- #randomise_question_order = models.BooleanField(
+ # randomise_question_order = models.BooleanField(
# help_text="If an exam should randomise the order of questions in RTS.",
- #)
+ # )
include_history = models.BooleanField(
help_text="If an exam should include history when taking",
@@ -883,20 +889,20 @@ class ExamBase(ExamOrCollectionGenericBase):
self.recreate_json = recreate_json
super().save(*args, **kwargs)
-
- #def clean(self, *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
-
- This should be used in preference to exam_questions.all() as it
+
+ This should be used in preference to exam_questions.all() as it
will order the questions correctly"""
return self.exam_questions.all().order_by("examquestiondetail__sort_order")
- def order_questions(self):
+ def order_questions(self):
"""Modifies the examquestiondetail sort_order to sequentially order the cases"""
for n, c in enumerate(self.examquestiondetail_set.all().order_by("sort_order")):
c.sort_order = n
@@ -1067,12 +1073,12 @@ class ExamBase(ExamOrCollectionGenericBase):
class ExamUserStatus(models.Model):
datetime = models.DateTimeField(auto_now_add=True)
- #cid_user: "CidUser" = models.ForeignKey(
+ # cid_user: "CidUser" = models.ForeignKey(
# "CidUser", blank=True, null=True, on_delete=models.SET_NULL
- #)
- #user_user = models.ForeignKey(
+ # )
+ # user_user = models.ForeignKey(
# settings.AUTH_USER_MODEL, blank=True, null=True, on_delete=models.SET_NULL
- #)
+ # )
status = models.CharField(max_length=255)
extra = models.CharField(max_length=255)
@@ -1080,7 +1086,9 @@ class ExamUserStatus(models.Model):
object_id = models.PositiveIntegerField()
exam = GenericForeignKey("content_type", "object_id")
- cid_user_exam = models.ForeignKey("CidUserExam", blank=True, null=True, on_delete=models.SET_NULL)
+ cid_user_exam = models.ForeignKey(
+ "CidUserExam", blank=True, null=True, on_delete=models.SET_NULL
+ )
def __str__(self):
self.cid_user_exam: "CidUserExam"
@@ -1393,10 +1401,11 @@ def get_next_cid():
class CidUserExam(models.Model):
"""This holds the current status of the exam for a candidate
-
- Contrast with ExamUserStatus which holds the status changes for a candidate
+
+ Contrast with ExamUserStatus which holds the status changes for a candidate
(and should be merged into here)
"""
+
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField()
exam = GenericForeignKey("content_type", "object_id")
@@ -1460,9 +1469,22 @@ CID_GROUP_EXAMS = (
)
-class CidUserGroup(models.Model):
- name = models.CharField(blank=True, max_length=50)
- archive = models.BooleanField(default=False)
+class BaseUserGroup(models.Model):
+ name = models.CharField(blank=True, max_length=50, help_text="Name of the Group")
+ archive = models.BooleanField(
+ default=False,
+ help_text="Archived groups remain on the test system but are not displayed by default",
+ )
+
+ open_access = models.BooleanField(
+ default=False, help_text="If the group is freely accessible to use"
+ )
+
+ class Meta:
+ abstract = True
+
+
+class CidUserGroup(BaseUserGroup):
def __str__(self) -> str:
return str(self.name)
@@ -1495,15 +1517,7 @@ USER_GROUP_EXAMS = (
)
-class UserUserGroup(models.Model):
- name = models.CharField(
- blank=True, max_length=50, help_text="Name of the User Group"
- )
- archive = models.BooleanField(
- default=False,
- help_text="Archived groups remain on the test system but are not displayed by default",
- )
-
+class UserUserGroup(BaseUserGroup):
users = models.ManyToManyField(
settings.AUTH_USER_MODEL,
blank=True,
diff --git a/generic/templates/generic/exam_candidate_headers.html b/generic/templates/generic/exam_candidate_headers.html
new file mode 100644
index 00000000..140534bc
--- /dev/null
+++ b/generic/templates/generic/exam_candidate_headers.html
@@ -0,0 +1,4 @@
+Overview
+\ Edit CIDs
+\ Edit User
+\ Edit Groups
\ No newline at end of file
diff --git a/generic/templates/generic/exam_cids_edit.html b/generic/templates/generic/exam_cids_edit.html
index 2606d148..f37921f3 100644
--- a/generic/templates/generic/exam_cids_edit.html
+++ b/generic/templates/generic/exam_cids_edit.html
@@ -1,88 +1,87 @@
+{% include "generic/exam_candidate_headers.html" %}
+
CID Users
+{% if groups %}
+ Users from the following groups are available: {% for group in groups %}{{group}}, {% endfor %} .
+{% else %}
+ This exam is not associated with any user groups. Edit and add a group to enable user management.
+{% endif %}
+
+ {% for cid in current_cid_users %}
-
-
CID Users
- {% if groups %}
- Users from the following groups are available: {% for group in groups %}{{group}}, {% endfor %} .
- {% else %}
- This exam is not associated with any user groups. Edit and add a group to enable user management.
- {% endif %}
-
- {% for cid in current_cid_users %}
-
-