improve exam group management

This commit is contained in:
Ross
2024-08-12 10:33:15 +01:00
parent bd1bb54f9f
commit 6fe2bae8a8
22 changed files with 352 additions and 155 deletions
+43 -16
View File
@@ -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")
@@ -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),
),
]
+59 -45
View File
@@ -437,13 +437,12 @@ class SeriesBase(models.Model):
if series_number is not None:
series_html = format_html(
"<span class='series-block-series-number'>Series {}</span>{}<br>",
series_number
series_number,
)
description = ""
if self.description:
description = format_html("{}<br/>", self.description)
return format_html(
"""{}{}
<span>
@@ -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,
@@ -0,0 +1,4 @@
<a href="{% url exam.get_app_name|add:':exam_cids' exam.id %}" title="Candidate overview">Overview</a>
\ <a href="{% url exam.get_app_name|add:':exam_cids_edit' exam.id %}" title="Edit the Exam CID candidates">Edit CIDs</a>
\ <a href="{% url exam.get_app_name|add:':exam_users_edit' exam.id %}" title="Edit the Exam User candidates">Edit User</a>
\ <a href="{% url exam.get_app_name|add:':exam_groups_edit' exam.id %}" title="Edit the Exam Groups">Edit Groups</a>
+73 -74
View File
@@ -1,88 +1,87 @@
{% include "generic/exam_candidate_headers.html" %}
<h3>CID Users</h3>
{% if groups %}
Users from the following groups are available: {% for group in groups %}<a href='{{group.get_absolute_url}}'>{{group}}</a>, {% endfor %} .
{% else %}
This exam is not associated with any user groups. <a href="{% url exam.app_name|add:':exam_groups_edit' exam.pk %}">Edit and add a group</a> to enable user management.
{% endif %}
<ol>
{% for cid in current_cid_users %}
<h3>CID Users</h3>
{% if groups %}
Users from the following groups are available: {% for group in groups %}<a href='{{group.get_absolute_url}}'>{{group}}</a>, {% endfor %} .
{% else %}
This exam is not associated with any user groups. <a href="{% url exam.app_name|add:':exam_update' exam.pk %}">Edit and add a group</a> to enable user management.
{% endif %}
<ol>
{% for cid in current_cid_users %}
<li class="current"><a href="{% url 'generic:update_cid' cid.pk %}">{{cid.cid}}</a> [{{cid.passcode}}] {{cid.name}} /
Email: {{cid.email}} <button class="toggle-btn" data-pk="{{cid.pk}}" data-cid="{{cid.cid}}">Toggle</button>
</li>
{% endfor %}
{% for cid in available_cid_users %}
<li><a href="{% url 'generic:update_cid' cid.pk %}">{{cid.cid}}</a> [{{cid.passcode}}] {{cid.name}} /
Email: {{cid.email}} <button class="toggle-btn" data-pk="{{cid.pk}}" data-cid="{{cid.cid}}">Toggle</button>
</li>
{% endfor %}
</ol>
<li class="current"><a href="{% url 'generic:update_cid' cid.pk %}">{{cid.cid}}</a> [{{cid.passcode}}] {{cid.name}} /
Email: {{cid.email}} <button class="toggle-btn" data-pk="{{cid.pk}}" data-cid="{{cid.cid}}">Toggle</button>
</li>
{% endfor %}
{% for cid in available_cid_users %}
<li><a href="{% url 'generic:update_cid' cid.pk %}">{{cid.cid}}</a> [{{cid.passcode}}] {{cid.name}} /
Email: {{cid.email}} <button class="toggle-btn" data-pk="{{cid.pk}}" data-cid="{{cid.cid}}">Toggle</button>
</li>
{% endfor %}
</ol>
{% comment %} Hide the toggle button if there are no users (to add or remove) {% endcomment %}
{% if current_cid_users or available_cid_users %}
<p><button class="toggle-all-btn">Toggle all</button></p>
{% endif %}
<script>
$(document).ready(() => {
$(".toggle-all-btn").click((el) => {
$(".toggle-btn").click();
});
$(".toggle-btn").click((el) => {
let parent = $(el.target).parent()
$.ajax({
url: "{% url exam.app_name|add:':exam_json_edit' exam.pk %}",
data: {
csrfmiddlewaretoken: "{{csrf_token}}",
edit_cid_user: el.target.dataset.pk,
add: !parent.hasClass("current"),
},
type: "POST",
dataType: "json",
})
// $.ajax().done(), $.ajax().fail(), $ajax().always() are upto you. Add/change accordingly
.done(function (data) {
console.log("done", data);
{% if current_cid_users or available_cid_users %}
<p><button class="toggle-all-btn">Toggle all</button></p>
{% endif %}
if (data.status == "success") {
if (data.added) {
<script>
$(document).ready(() => {
$(".toggle-all-btn").click((el) => {
$(".toggle-btn").click();
});
$(".toggle-btn").click((el) => {
let parent = $(el.target).parent()
$.ajax({
url: "{% url exam.app_name|add:':exam_json_edit' exam.pk %}",
data: {
csrfmiddlewaretoken: "{{csrf_token}}",
edit_cid_user: el.target.dataset.pk,
add: !parent.hasClass("current"),
},
type: "POST",
dataType: "json",
})
// $.ajax().done(), $.ajax().fail(), $ajax().always() are upto you. Add/change accordingly
.done(function (data) {
console.log("done", data);
if (data.status == "success") {
if (data.added) {
parent.addClass("current");
toastr.info(`User ${el.target.dataset.cid} added to exam`)
} else {
} else {
parent.removeClass("current");
toastr.info(`User ${el.target.dataset.cid} removed from exam`)
}
} else {
toastr.error(data.status)
}
})
.fail(function (data) {
console.log("fail", data);
toastr.error(data.responseJSON.status)
} else {
toastr.error(data.status)
}
})
.fail(function (data) {
console.log("fail", data);
toastr.error(data.responseJSON.status)
})
.always(function () {
})
})
.always(function () {
})
});
});
})
</script>
<style>
.current {
color: lightgray;
}
.current::before {
content: "[ADDED TO EXAM] - "
}
})
</script>
<style>
.current {
color: lightgray;
}
.current::before {
content: "[ADDED TO EXAM] - "
}
button {
padding: 0;
}
</style>
button {
padding: 0;
}
</style>
@@ -0,0 +1,17 @@
{% extends exam.get_app_name|add:'/exams.html' %}
{% block content %}
<h1>Exam: {{exam}}</h1>
{% include "generic/exam_candidate_headers.html" %}
<h3>Groups</h3>
<form action="" method="post">
{% csrf_token %}
<table>
{{ form }}
</table>
<input type="submit" value="Submit">
</form>
{% endblock content %}
@@ -47,21 +47,24 @@ Markers:
{% if can_edit %}
<details>
<details class="answer-management">
<summary>Answer management</summary>
<div class="alert alert-danger">
<p>Manage answers for this collection. </p>
<div>
<div class="alert alert-danger">
<i class="bi bi-exclamation-diamond"></i> Manage answers for this collection. Please note these are <b>permanant</b> and cannot be undone.
</div>
Please note these are permanant and cannot be undone.
<details>
<summary>Click here to show management options</summary>
<button title="This will clear all user answers and attempts"
hx-post="{% url exam.get_app_name|add:':exam_reset_answers' exam.pk %}"
hx-swap="outerHTML"
hx-confirm="Are you sure you want to reset all answers? This action cannot be undone."
>Reset all answers</button>
</details>
</div>
<button title="This will clear all user answers and attempts"
hx-post="{% url exam.get_app_name|add:':exam_reset_answers' exam.pk %}"
hx-swap="outerHTML"
hx-confirm="Are you sure you want to reset all answers? This action cannot be undone."
>Reset all answers</button>
</details>
{% endif %}
@@ -104,4 +107,13 @@ Author(s): {% for author in exam.author.all %}
{{ author }}{% if not forloop.last %}, {% endif %}
{% endfor %}<br/>
{% block css %}
<style>
.answer-management[open] {
border: 1px dashed lightgrey;
border-top: 1px dotted lightgrey;
}
</style>
{% endblock css %}
@@ -1,9 +1,9 @@
{% include "generic/exam_candidate_headers.html" %}
<h3>Users</h3>
{% if groups %}
Users from the following groups are available: {% for group in groups %}<a href='{{group.get_absolute_url}}'>{{group}}</a>, {% endfor %} .
{% else %}
This exam is not associated with any user groups. <a href="{% url exam.app_name|add:':exam_update' exam.pk %}">Edit and add a group</a> to enable user management.
This exam is not associated with any user groups. <a href="{% url exam.app_name|add:':exam_groups_edit' exam.pk %}">Edit and add a group</a> to enable user management.
{% endif %}
<ol>
{% for user in current_user_users %}
+1
View File
@@ -343,6 +343,7 @@ def generic_exam_urls(generic_exam_view: GenericExamViews):
path("exam/", generic_exam_view.exam_list, name="exam_list"),
path("exam/all", generic_exam_view.exam_list_all, name="exam_list_all"),
path("exam/<int:exam_id>/cids", generic_exam_view.exam_cids, name="exam_cids"),
#path("exam/<int:exam_id>/groups", generic_exam_view.exam_groups_edit, name="exam_groups_edit"),
path(
"exam/<int:exam_id>/reset_answers",
generic_exam_view.exam_reset_answers,
+31 -1
View File
@@ -1042,6 +1042,23 @@ class ExamViews(View, LoginRequiredMixin):
return render(request, "exam_cids.html", context)
#def exam_groups_edit(self, request, exam_id):
# exam = get_object_or_404(self.Exam, pk=exam_id)
# if not request.user.groups.filter(name="cid_user_manager").exists():
# # raise PermissionDenied
# if request.user not in exam.author.all():
# raise PermissionDenied
#
# context = {
# "exam": exam,
# #"groups": exam_groups,
# }
# return render(request, "exam_groups_edit.html", context)
def exam_users_edit(self, request, exam_id):
exam = get_object_or_404(self.Exam, pk=exam_id)
@@ -3482,4 +3499,17 @@ class SeriesImagesZipViewBase(SuperuserRequiredMixin, View):
response = HttpResponse(temp_file, content_type='application/zip')
response['Content-Disposition'] = 'attachment; filename=%s' % self.get_archive_name(request)
response['Content-Length'] = file_size
return response
return response
class ExamGroupsUpdateBase(
RevisionMixin, CheckCanEditMixin, LoginRequiredMixin, AuthorRequiredMixin, UpdateView
):
template_name = "generic/exam_groups_edit.html"
def get_success_url(self) -> str:
return reverse_lazy(f"{self.object.get_app_name()}:exam_cids", kwargs={"exam_id": self.object.pk})
def get_form_kwargs(self):
kwargs = super(ExamGroupsUpdateBase, self).get_form_kwargs()
kwargs.update({"user": self.request.user})
return kwargs