Add CID user support in collection history and reset answers functionality
This commit is contained in:
@@ -18,6 +18,8 @@ from .models import (
|
||||
SeriesDetail,
|
||||
Resource,
|
||||
CaseDisplaySet,
|
||||
UserReportAnswer,
|
||||
CidReportAnswer,
|
||||
)
|
||||
|
||||
from django.forms import ModelForm
|
||||
@@ -48,6 +50,8 @@ admin.site.register(UncategorisedDicom)
|
||||
admin.site.register(SeriesDetail)
|
||||
admin.site.register(Resource)
|
||||
admin.site.register(CaseDisplaySet)
|
||||
admin.site.register(UserReportAnswer)
|
||||
admin.site.register(CidReportAnswer)
|
||||
|
||||
|
||||
class DifferentialInline(admin.TabularInline):
|
||||
|
||||
@@ -1254,6 +1254,13 @@ class CaseDetail(models.Model):
|
||||
except UserReportAnswer.DoesNotExist:
|
||||
return None
|
||||
|
||||
def get_cid_answers(self, cid):
|
||||
"""Returns the cid users answers as a json string"""
|
||||
try:
|
||||
return CidReportAnswer.objects.get(question=self, cid=cid)
|
||||
except CidReportAnswer.DoesNotExist:
|
||||
return None
|
||||
|
||||
def default_viewerstate_string(self):
|
||||
return json.dumps(self.default_viewerstate) if self.default_viewerstate else "{}"
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div id="timer-htmx-target" style="display:none;"></div>
|
||||
<div id="autosubmit-toast-container" style="position:fixed; top:16px; right:16px; z-index:10500;"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -412,24 +413,45 @@
|
||||
$timer.text('0:00');
|
||||
$progressInner.css('width', '0%');
|
||||
$progressInner.css('background', '#dc3545');
|
||||
var $next = $form.find('button[name="next"]');
|
||||
var $finish = $form.find('button[name="finish"]');
|
||||
//var $next = $form.find('button[name="next"]');
|
||||
//var $finish = $form.find('button[name="finish"]');
|
||||
|
||||
// Instead of navigating away, submit via fetch with timed_out flag
|
||||
// Use HTMX to POST the timed_out flag and then lock the UI
|
||||
|
||||
var onAfter = function (evt) {
|
||||
try {
|
||||
// lock the form UI in-place
|
||||
$form.find('input, textarea, select, button').prop('disabled', true);
|
||||
$form.addClass('timed-out-locked');
|
||||
$('#question-timer').text('Locked');
|
||||
var resp = null;
|
||||
try {
|
||||
var xhr = evt && evt.detail && evt.detail.xhr;
|
||||
if (xhr && xhr.responseText) {
|
||||
resp = JSON.parse(xhr.responseText);
|
||||
}
|
||||
} catch (e) {
|
||||
console.debug('No JSON response from timed_out request', e);
|
||||
}
|
||||
|
||||
// hide the progress bar
|
||||
$('#question-timer-progress, #question-timer-progress-outer, #question-timer-progress-inner').hide();
|
||||
// ensure inner shows empty state if something relies on it
|
||||
$('#question-timer-progress-inner').css({ width: '0%', background: '#dc3545' });
|
||||
// Only disable save buttons to prevent further saves
|
||||
var $saveBtns = $form.find('button[name="save"], #id_answer');
|
||||
if ($saveBtns.length) {
|
||||
$saveBtns.prop('disabled', true);
|
||||
}
|
||||
|
||||
// Visual feedback: set timer to Locked and progress to red
|
||||
$('#question-timer').text('Locked');
|
||||
$progressInner.css('background', '#dc3545');
|
||||
$progressInner.css('width', '0%');
|
||||
|
||||
var msg = 'Answer auto-submitted';
|
||||
if (resp && resp.submitted_at) {
|
||||
try {
|
||||
var dt = new Date(resp.submitted_at);
|
||||
msg += ' at ' + dt.toLocaleTimeString();
|
||||
} catch (e) {}
|
||||
}
|
||||
toastr.info(msg);
|
||||
} catch (e) {
|
||||
console.error('Error locking UI after htmx request', e);
|
||||
console.error('Error handling timed_out htmx response', e);
|
||||
} finally {
|
||||
htmx.off('htmx:afterRequest', onAfter);
|
||||
}
|
||||
|
||||
@@ -3,26 +3,52 @@
|
||||
{% block content %}
|
||||
<h2>{{collection.name}}</h2>
|
||||
|
||||
<h3>Users</h3>
|
||||
|
||||
<ul>
|
||||
{% for userexam in userexams %}
|
||||
<li id="user-history-{{ userexam.user_user.pk }}">
|
||||
<b><a href="{% url 'atlas:collection_history_user' collection.pk userexam.user_user.pk %}">{{userexam.get_user_name}}</a><b><br/>
|
||||
Completed: {{userexam.completed}}<br/>
|
||||
Started: {{userexam.start_time}}, Ended: {{userexam.end_time}}<br/>
|
||||
{% if userexams %}
|
||||
<h3>Users</h3>
|
||||
|
||||
<button
|
||||
hx-post="{% url 'atlas:collection_reset_answers_user' collection.pk userexam.user_user.pk %}"
|
||||
hx-target="#user-history-{{ userexam.user_user.pk }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Are you sure you want to delete this user's collection history?"
|
||||
class="btn btn-danger btn-sm remove-button">
|
||||
Delete
|
||||
</button>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<ul>
|
||||
{% for userexam in userexams %}
|
||||
<li id="user-history-{{ userexam.user_user.pk }}">
|
||||
<b><a href="{% url 'atlas:collection_history_user' collection.pk userexam.user_user.pk %}">{{userexam.get_user_name}}</a><b><br/>
|
||||
Completed: {{userexam.completed}}<br/>
|
||||
Started: {{userexam.start_time}}, Ended: {{userexam.end_time}}<br/>
|
||||
|
||||
<button
|
||||
hx-post="{% url 'atlas:collection_reset_answers_user' collection.pk userexam.user_user.pk %}"
|
||||
hx-target="#user-history-{{ userexam.user_user.pk }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Are you sure you want to delete this user's collection history?"
|
||||
class="btn btn-danger btn-sm remove-button">
|
||||
Delete
|
||||
</button>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% if cidexams %}
|
||||
<h3>CID Users</h3>
|
||||
|
||||
<ul>
|
||||
{% for cidexam in cidexams %}
|
||||
<li id="cid-history-{{ cidexam.cid_user.pk }}">
|
||||
<b><a href="{% url 'atlas:collection_history_ciduser' collection.pk cidexam.cid_user.pk %}">{{cidexam.cid_user}}</a><b><br/>
|
||||
Completed: {{cidexam.completed}}<br/>
|
||||
Started: {{cidexam.start_time}}, Ended: {{cidexam.end_time}}<br/>
|
||||
|
||||
<button
|
||||
hx-post="{% url 'atlas:collection_reset_answers_ciduser' collection.pk cidexam.cid_user.pk %}"
|
||||
hx-target="#cid-history-{{ cidexam.cid_user.pk }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Are you sure you want to delete this CID user's collection history?"
|
||||
class="btn btn-danger btn-sm remove-button">
|
||||
Delete
|
||||
</button>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -10,39 +10,53 @@
|
||||
<li class="case">
|
||||
|
||||
<h4>{{forloop.counter}} / Case: {{casedetail.case.title}}</h4>
|
||||
{{user_answer.started_at}} - {{user_answer.submitted_at}}
|
||||
{% if request.user.is_superuser and user_answer %}
|
||||
(<a href="{% url 'admin:atlas_userreportanswer_change' user_answer.pk %}" target="_blank">Edit in admin</a>)
|
||||
{% endif %}
|
||||
<br/>
|
||||
{% if not user_answer %}
|
||||
<span class="case-not-answered">Case not answered.</span>
|
||||
{% else %}
|
||||
|
||||
|
||||
|
||||
<div class="answer-block">
|
||||
{% for value, user_answer, correct_answer, answer_is_correct, automark in user_answer.get_correct_json_answers %}
|
||||
{% if not user_answer %}
|
||||
Not answered
|
||||
{% else %}
|
||||
<div class="{% if answer_is_correct %}
|
||||
correct
|
||||
{% else %}
|
||||
incorrect
|
||||
{% endif %}
|
||||
{% if automark %}
|
||||
automark
|
||||
{% if user_answer.answer %}
|
||||
{{user_answer.answer}}
|
||||
|
||||
{% else %}
|
||||
{{user_answer.json_answer}}
|
||||
{% for value, user_answer, correct_answer, answer_is_correct, automark in user_answer.get_correct_json_answers %}
|
||||
{% if not user_answer %}
|
||||
Not answered
|
||||
{% else %}
|
||||
<div class="{% if answer_is_correct %}
|
||||
correct
|
||||
{% else %}
|
||||
incorrect
|
||||
{% endif %}
|
||||
{% if automark %}
|
||||
automark
|
||||
{% endif %}
|
||||
|
||||
">
|
||||
<h5>{{value.title}}</h5>
|
||||
{{value.description}}
|
||||
<div
|
||||
>
|
||||
Answer : {{user_answer}}
|
||||
|
||||
{% if not answer_is_correct %}
|
||||
<br/>Correct answer: {{correct_answer}}
|
||||
{% endif %}
|
||||
|
||||
">
|
||||
<h5>{{value.title}}</h5>
|
||||
{{value.description}}
|
||||
<div
|
||||
>
|
||||
Answer : {{user_answer}}
|
||||
|
||||
{% if not answer_is_correct %}
|
||||
<br/>Correct answer: {{correct_answer}}
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
@@ -128,6 +128,11 @@ urlpatterns = [
|
||||
views.collection_history_user,
|
||||
name="collection_history_user",
|
||||
),
|
||||
path(
|
||||
"collection/<int:exam_id>/history/<int:cid>/ciduser",
|
||||
views.collection_history_ciduser,
|
||||
name="collection_history_ciduser",
|
||||
),
|
||||
|
||||
path(
|
||||
"collection/<int:pk>/user_status",
|
||||
@@ -201,6 +206,11 @@ urlpatterns = [
|
||||
views.collection_reset_answers_user,
|
||||
name="collection_reset_answers_user",
|
||||
),
|
||||
path(
|
||||
"collection/<int:exam_id>/cid/<int:cid>/reset_answers",
|
||||
views.collection_reset_answers_ciduser,
|
||||
name="collection_reset_answers_ciduser",
|
||||
),
|
||||
#path(
|
||||
# "question_schemas_preset",
|
||||
# views.question_schemas_preset,
|
||||
|
||||
+54
-3
@@ -2102,9 +2102,11 @@ def collection_history(request, exam_id: int):
|
||||
"sort_order"
|
||||
)
|
||||
|
||||
userexams = collection.get_cid_user_exams()
|
||||
userexams = collection.get_user_exams()
|
||||
cidexams = collection.get_cid_exams()
|
||||
|
||||
print(userexams)
|
||||
print("useruserexams:", userexams)
|
||||
print("cidexams:", cidexams)
|
||||
|
||||
return render(
|
||||
request,
|
||||
@@ -2114,6 +2116,7 @@ def collection_history(request, exam_id: int):
|
||||
"casesdetails": casedetails,
|
||||
"can_edit": True,
|
||||
"userexams": userexams,
|
||||
"cidexams": cidexams,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -2147,6 +2150,32 @@ def collection_history_user(request, exam_id: int, user_pk: int):
|
||||
)
|
||||
|
||||
|
||||
@user_is_collection_author_or_atlas_editor
|
||||
def collection_history_ciduser(request, exam_id: int, cid: int):
|
||||
collection = get_object_or_404(CaseCollection, pk=exam_id)
|
||||
|
||||
casedetails = CaseDetail.objects.filter(
|
||||
collection=collection,
|
||||
).order_by("sort_order")
|
||||
|
||||
print(casedetails)
|
||||
|
||||
user_answers = [
|
||||
(casedetail, casedetail.get_cid_answers(cid)) for casedetail in casedetails
|
||||
]
|
||||
|
||||
return render(
|
||||
request,
|
||||
"atlas/collection_history_user.html",
|
||||
{
|
||||
"collection": collection,
|
||||
"user": cid,
|
||||
"casedetails": casedetails,
|
||||
"user_answers": user_answers,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
def collection_take_start(request, pk, cid=None, passcode=None):
|
||||
@@ -2173,7 +2202,7 @@ def collection_take_start(request, pk, cid=None, passcode=None):
|
||||
|
||||
cid_exam = None
|
||||
if collection.collection_type == "REV" or valid_user:
|
||||
cid_exam = collection.get_cid_user_exams(user_user=request.user).first()
|
||||
cid_exam = collection.get_cid_and_user_exams(user_user=request.user).first()
|
||||
|
||||
template_variables = {
|
||||
"collection": collection,
|
||||
@@ -3522,6 +3551,7 @@ def collection_reset_answers_user_list(request, exam_id: int):
|
||||
raise Http404("Invalid request")
|
||||
|
||||
|
||||
@user_is_collection_author_or_atlas_editor
|
||||
def collection_reset_answers_user(request, exam_id: int, user_id: int):
|
||||
if request.htmx:
|
||||
collection = get_object_or_404(CaseCollection, pk=exam_id)
|
||||
@@ -3542,6 +3572,27 @@ def collection_reset_answers_user(request, exam_id: int, user_id: int):
|
||||
else:
|
||||
raise Http404("Invalid request")
|
||||
|
||||
@user_is_collection_author_or_atlas_editor
|
||||
def collection_reset_answers_ciduser(request, exam_id: int, cid: int):
|
||||
if request.htmx:
|
||||
collection = get_object_or_404(CaseCollection, pk=exam_id)
|
||||
|
||||
# Select all case details (answers are linked to these)
|
||||
case_details = collection.casedetail_set.all().prefetch_related()
|
||||
|
||||
# Delete all answers
|
||||
for case in case_details:
|
||||
cid_answers = case.cidreportanswer_set.filter(cid=cid)
|
||||
cid_answers.delete()
|
||||
|
||||
collection.cid_users.filter(cid=cid).delete()
|
||||
collection.exam_user_status.filter(cid_user_exam__cid__pk=cid).delete()
|
||||
|
||||
return HttpResponse(f"CID [{cid}] answers deleted")
|
||||
|
||||
else:
|
||||
raise Http404("Invalid request")
|
||||
|
||||
|
||||
def collection_reset_answers(request, exam_id: int):
|
||||
if request.htmx:
|
||||
|
||||
+31
-1
@@ -905,9 +905,10 @@ class ExamOrCollectionGenericBase(models.Model, AuthorMixin):
|
||||
def get_question_user_user_answer(self, question_index, user):
|
||||
raise NotImplementedError
|
||||
|
||||
def get_cid_user_exams(
|
||||
def get_cid_and_user_exams(
|
||||
self, cid_user: Optional["CidUser"] = None, user_user: User | None = None
|
||||
) -> "CidUserExam":
|
||||
"""Returns a queryset of CidUserExam for this exam/collection"""
|
||||
content_type = ContentType.objects.get_for_model(self)
|
||||
if cid_user is None and user_user is None:
|
||||
return CidUserExam.objects.filter(
|
||||
@@ -924,6 +925,35 @@ class ExamOrCollectionGenericBase(models.Model, AuthorMixin):
|
||||
content_type=content_type, object_id=self.pk, user_user=user_user
|
||||
)
|
||||
|
||||
def get_cid_exams(self, cid_user: Optional["CidUser"]= None) -> "CidUserExam":
|
||||
"""Returns a queryset of CidUserExam for this exam/collection"""
|
||||
content_type = ContentType.objects.get_for_model(self)
|
||||
|
||||
if cid_user is None:
|
||||
# Only return pure CID entries (cid_user set, user_user null)
|
||||
return CidUserExam.objects.filter(
|
||||
content_type=content_type,
|
||||
object_id=self.pk,
|
||||
cid_user__isnull=False,
|
||||
user_user__isnull=True,
|
||||
)
|
||||
return CidUserExam.objects.filter(
|
||||
content_type=content_type, object_id=self.pk, cid_user=cid_user
|
||||
)
|
||||
|
||||
def get_user_exams(self, user_user: Optional[User] = None) -> "CidUserExam":
|
||||
"""Returns a queryset of CidUserExam for this exam/collection"""
|
||||
content_type = ContentType.objects.get_for_model(self)
|
||||
if user_user is None:
|
||||
return CidUserExam.objects.filter(
|
||||
content_type=content_type, object_id=self.pk, user_user__isnull=False, cid_user__isnull=True
|
||||
)
|
||||
return CidUserExam.objects.filter(
|
||||
content_type=content_type, object_id=self.pk, user_user=user_user
|
||||
)
|
||||
|
||||
|
||||
|
||||
def clone_model(self):
|
||||
M2M_fields = (
|
||||
"exam_questions",
|
||||
|
||||
Reference in New Issue
Block a user