Add CidUserExam management features: forms, views, and templates for exam records
This commit is contained in:
@@ -20,6 +20,7 @@ from django.contrib.auth.models import User
|
||||
|
||||
from generic.models import (
|
||||
CidUser,
|
||||
CidUserExam,
|
||||
CidUserGroup,
|
||||
Examination,
|
||||
ExamCollection,
|
||||
@@ -541,6 +542,19 @@ class CidUserExamForm(ModelForm):
|
||||
fields = []
|
||||
|
||||
|
||||
class CidUserExamRecordForm(ModelForm):
|
||||
class Meta:
|
||||
model = CidUserExam
|
||||
fields = [
|
||||
"start_time",
|
||||
"end_time",
|
||||
"completed",
|
||||
"manual_submission",
|
||||
"share_with_supervisor",
|
||||
"results_emailed_status",
|
||||
]
|
||||
|
||||
|
||||
class CidUserGroupModelChoiceField(ModelMultipleChoiceField):
|
||||
def label_from_instance(self, obj):
|
||||
return f"{obj.cid} [{obj.email}]"
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{% extends "generic/base.html" %}
|
||||
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Edit CidUserExam record</h2>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
<button class="btn btn-primary">Save</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -21,17 +21,25 @@
|
||||
<ol class="cid-candidate-list">
|
||||
{% for cid in cid_users %}
|
||||
|
||||
<li class="cid-user" data-cid={{cid.cid}}><div class="cid-data">
|
||||
<i class="bi bi-clock-history"
|
||||
title="View user exam history"
|
||||
hx-get="{% url exam.app_name|add:':exam_user_status_cid' exam.id cid.cid %}"
|
||||
hx-target=".modal-content"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#history-modal"
|
||||
_="on htmx:afterOnLoad put {{cid.cid}} into #history-user.innerHTML "
|
||||
></i>
|
||||
<a href="{% url 'generic:update_cid' cid.pk %}">{{cid.cid}}</a> [{{cid.passcode}}]</div><div class="cid-name">{{cid.name}}</div> <div class="cid-email">Email: {{cid.email}}</div>
|
||||
</li>
|
||||
<li class="cid-user" data-cid={{cid.cid}}>
|
||||
<div class="cid-data d-flex align-items-center">
|
||||
<i class="bi bi-clock-history me-2"
|
||||
title="View user exam history"
|
||||
hx-get="{% url exam.app_name|add:':exam_user_status_cid' exam.id cid.cid %}"
|
||||
hx-target=".modal-content"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#history-modal"
|
||||
_="on htmx:afterOnLoad put {{cid.cid}} into #history-user.innerHTML "
|
||||
></i>
|
||||
<span class="d-inline-flex align-items-center flex-nowrap">
|
||||
<a class="cid-link me-1" href="{% url 'generic:update_cid' cid.pk %}">{{cid.cid}}</a>
|
||||
<span class="text-muted">[{{cid.passcode}}]</span>
|
||||
<a title="View CID exam details" class="ms-2 d-inline-block cid-detail-link" hx-get="{% url 'generic:cid_user_exam_partial' exam.app_name exam.id cid.cid %}" hx-target=".modal-content" data-bs-toggle="modal" data-bs-target="#history-modal" role="button"><i class="bi bi-box-arrow-up-right"></i></a>
|
||||
</span>
|
||||
</div>
|
||||
<div class="cid-name">{{cid.name}}</div>
|
||||
<div class="cid-email">Email: {{cid.email}}</div>
|
||||
</li>
|
||||
|
||||
{% endfor %}
|
||||
</ol>
|
||||
@@ -132,6 +140,11 @@
|
||||
|
||||
})
|
||||
</script>
|
||||
<style>
|
||||
/* Ensure action links are inline-block and don't overlap following content */
|
||||
.cid-data .cid-detail-link, .cid-data .cid-link { display: inline-block; }
|
||||
.cid-name { margin-top: 0.25rem; }
|
||||
</style>
|
||||
{% endblock js %}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<div class="p-3">
|
||||
<h4>CidUserExam: {% if cid_user_exam.cid_user %}{{ cid_user_exam.cid_user.cid }}{% elif cid_user_exam.user_user %}{{ cid_user_exam.user_user.username }}{% else %}-{% endif %}</h4>
|
||||
<table class="table table-sm">
|
||||
<tbody>
|
||||
<tr><th>CID</th><td>{% if cid_user_exam.cid_user %}{{ cid_user_exam.cid_user.cid }}{% else %}-{% endif %}</td></tr>
|
||||
<tr><th>User</th><td>{% if cid_user_exam.user_user %}{{ cid_user_exam.user_user.username }}{% else %}-{% endif %}</td></tr>
|
||||
<tr><th>Start time</th><td>{{ cid_user_exam.start_time }}</td></tr>
|
||||
<tr><th>End time</th><td>{{ cid_user_exam.end_time }}</td></tr>
|
||||
<tr><th>Completed</th><td>{{ cid_user_exam.completed }}</td></tr>
|
||||
<tr><th>Manual submission</th><td>{{ cid_user_exam.manual_submission }}</td></tr>
|
||||
<tr><th>Share with supervisor</th><td>{{ cid_user_exam.share_with_supervisor }}</td></tr>
|
||||
<tr><th>Results emailed status</th><td>{{ cid_user_exam.results_emailed_status }}</td></tr>
|
||||
<tr><th>CID User email</th><td>{% if cid_user_exam.cid_user %}{{ cid_user_exam.cid_user.email }}{% else %}-{% endif %}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="d-flex justify-content-end">
|
||||
{% if cid_user_exam %}
|
||||
<a class="btn btn-sm btn-outline-primary me-2" href="{% url 'generic:update_cid_user_exam' cid_user_exam.pk %}">Edit</a>
|
||||
{% endif %}
|
||||
<button class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -124,6 +124,18 @@ urlpatterns = [
|
||||
views.CidUserGroupUpdate.as_view(),
|
||||
name="cid_group_update",
|
||||
),
|
||||
|
||||
# HTMX partial to show a CidUserExam record for an exam (used from exam lists)
|
||||
path(
|
||||
"exam/<str:app_name>/<int:exam_id>/cid/<int:cid>/ciduserexam",
|
||||
views.cid_user_exam_partial,
|
||||
name="cid_user_exam_partial",
|
||||
),
|
||||
path(
|
||||
"cids/ciduserexam/<int:pk>/update",
|
||||
views.CidUserExamRecordUpdate.as_view(),
|
||||
name="update_cid_user_exam",
|
||||
),
|
||||
path(
|
||||
"cids/group/<int:pk>/delete",
|
||||
views.CidUserGroupDelete.as_view(),
|
||||
|
||||
@@ -110,6 +110,26 @@ from .models import (
|
||||
)
|
||||
|
||||
|
||||
@user_is_cid_user_manager
|
||||
def cid_user_exam_partial(request, app_name: str, exam_id: int, cid: int):
|
||||
"""Return an HTMX partial showing details for a CidUserExam record.
|
||||
|
||||
Matches by content_type app_label and object_id (exam id), and cid value.
|
||||
"""
|
||||
# Find the matching CidUserExam
|
||||
try:
|
||||
cux = CidUserExam.objects.select_related("cid_user", "user_user", "content_type").filter(
|
||||
content_type__app_label=app_name, object_id=exam_id, cid_user__cid=cid
|
||||
).first()
|
||||
except Exception:
|
||||
cux = None
|
||||
|
||||
if not cux:
|
||||
return HttpResponse("No record found", status=404)
|
||||
|
||||
return render(request, "generic/partials/ciduserexam_detail_partial.html", {"cid_user_exam": cux})
|
||||
|
||||
|
||||
from rapids.models import (
|
||||
Rapid as RapidQuestion,
|
||||
ExamQuestionDetail as RapidsExamQuestionDetail,
|
||||
@@ -5295,6 +5315,8 @@ class CidUserUpdate(CidManagerRequiredMixin, UpdateView):
|
||||
|
||||
|
||||
class CidUserExamUpdate(CidManagerRequiredMixin, UpdateView):
|
||||
# This view edits the CidUser (which stores which exams the CID is registered for).
|
||||
# Keep model as `CidUser` so existing URL `update_cid_exams` continues to work.
|
||||
model = CidUser
|
||||
form_class = CidUserExamForm
|
||||
template_name = "generic/ciduserexam_form.html"
|
||||
@@ -5316,6 +5338,22 @@ class CidUserGroupCreate(RevisionMixin, CidManagerRequiredMixin, CreateView):
|
||||
form_class = CidUserGroupForm
|
||||
|
||||
|
||||
class CidUserExamRecordUpdate(CidManagerRequiredMixin, UpdateView):
|
||||
"""Update a CidUserExam record (individual exam record for a CID/user).
|
||||
|
||||
Separate from `CidUserExamUpdate` which edits a `CidUser`'s exam memberships.
|
||||
"""
|
||||
model = CidUserExam
|
||||
form_class = None
|
||||
template_name = "generic/ciduserexam_record_form.html"
|
||||
|
||||
def get_form_class(self):
|
||||
# import locally to avoid circular imports at module import time
|
||||
from .forms import CidUserExamRecordForm
|
||||
|
||||
return CidUserExamRecordForm
|
||||
|
||||
|
||||
class CidUserGroupUpdate(RevisionMixin, CidManagerRequiredMixin, UpdateView):
|
||||
model = CidUserGroup
|
||||
form_class = CidUserGroupForm
|
||||
|
||||
Reference in New Issue
Block a user