Compare commits
52
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e687b55e6a | ||
|
|
d19e937bd4 | ||
|
|
fa68034f03 | ||
|
|
68f45c8561 | ||
|
|
be68fcd0b9 | ||
|
|
e819645278 | ||
|
|
9efec6b79b | ||
|
|
3fb5bc2ae8 | ||
|
|
6700c6a7d2 | ||
|
|
55621b2dcc | ||
|
|
b417266e56 | ||
|
|
6d589ef630 | ||
|
|
8addc900f5 | ||
|
|
a055e138c4 | ||
|
|
132f3c48c9 | ||
|
|
07f121fb69 | ||
|
|
c7fa3a096c | ||
|
|
196d472608 | ||
|
|
1a04064e6b | ||
|
|
a49f198d26 | ||
|
|
8663482a6b | ||
|
|
5e4fea38d7 | ||
|
|
45e50e9e93 | ||
|
|
8dac99b6d3 | ||
|
|
a4f4cb9e96 | ||
|
|
5e84ba3178 | ||
|
|
4050193cec | ||
|
|
c975b3c967 | ||
|
|
329394dcf0 | ||
|
|
6cb1303630 | ||
|
|
9b9cd8c813 | ||
|
|
593eafd427 | ||
|
|
7f68fc23c6 | ||
|
|
bad0d400a7 | ||
|
|
ae644f9c03 | ||
|
|
5b8896e2b2 | ||
|
|
66711f5c15 | ||
|
|
d342e7e047 | ||
|
|
bdb7aff5d1 | ||
|
|
ac9faeccbd | ||
|
|
1e7d765fc2 | ||
|
|
e3055e3998 | ||
|
|
f6f208b065 | ||
|
|
815d853599 | ||
|
|
9cffdb3636 | ||
|
|
a2a43e51f9 | ||
|
|
101326fea2 | ||
|
|
b157fe8b00 | ||
|
|
33b6d0d935 | ||
|
|
3daad1b83b | ||
|
|
512b222ccc | ||
|
|
5294c5f2f6 |
@@ -6,27 +6,31 @@
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div id="view-filter-options">
|
||||
<div id="view-filter-options">
|
||||
<h3>Filter Anatomy Questions</h3>
|
||||
<form action="" method="get">
|
||||
{{ filter.form }}
|
||||
<input class="btn btn-primary btn-sm mt-1 mb-1" type="submit" />
|
||||
</form>
|
||||
</div>
|
||||
{% render_table table %}
|
||||
</div>
|
||||
{% render_table table %}
|
||||
|
||||
<button id="button-select-add-exam"
|
||||
data-exam_json_edit_url="{% url 'generic:generic_exam_json_edit' %}"
|
||||
data-exam_list_url="{% url 'api-1:anatomy_user_exams' %}"
|
||||
data-type="anatomy"
|
||||
data-csrf="{{ csrf_token}}">Add selected questions to
|
||||
exam</button>
|
||||
<div id="exam-options"></div>
|
||||
<button id="button-select-add-exam"
|
||||
hx-get="{% url 'generic:generic_exam_htmx' %}?type=anatomy"
|
||||
hx-target="#action-result"
|
||||
hx-swap="innerHTML"
|
||||
data-exam_json_edit_url="{% url 'generic:generic_exam_json_edit' %}"
|
||||
data-exam_list_url="{% url 'api-1:anatomy_user_exams' %}"
|
||||
data-type="anatomy"
|
||||
data-csrf="{{ csrf_token }}">
|
||||
Add selected questions to exam
|
||||
</button>
|
||||
<div id="exam-options"></div>
|
||||
|
||||
<script type="text/javascript">
|
||||
<script type="text/javascript">
|
||||
$(document).ready(function () {
|
||||
|
||||
|
||||
});
|
||||
</script>
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -27,6 +27,15 @@
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{% if finding.sbas_questions.all %}
|
||||
<h4>Related SBA Questions</h4>
|
||||
<ul>
|
||||
{% for q in finding.sbas_questions.all %}
|
||||
<li><a href="{{ q.get_absolute_url }}">{% if q.title %}{{ q.title }}{% else %}{{ q.get_stem_stripped|truncatechars:140 }}{% endif %}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
|
||||
@@ -27,6 +27,15 @@
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{% if presentation.sbas_questions.all %}
|
||||
<h4>Related SBA Questions</h4>
|
||||
<ul>
|
||||
{% for q in presentation.sbas_questions.all %}
|
||||
<li><a href="{{ q.get_absolute_url }}">{% if q.title %}{{ q.title }}{% else %}{{ q.get_stem_stripped|truncatechars:140 }}{% endif %}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
|
||||
@@ -27,6 +27,15 @@
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
{% if structure.sbas_questions.all %}
|
||||
<h4>Related SBA Questions</h4>
|
||||
<ul>
|
||||
{% for q in structure.sbas_questions.all %}
|
||||
<li><a href="{{ q.get_absolute_url }}">{% if q.title %}{{ q.title }}{% else %}{{ q.get_stem_stripped|truncatechars:140 }}{% endif %}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
|
||||
+6
-1
@@ -2,7 +2,7 @@ from django.urls import path, include
|
||||
from django.views.generic import RedirectView, TemplateView
|
||||
|
||||
|
||||
from atlas.models import Condition, Finding, Presentation, Structure
|
||||
from atlas.models import Condition, Finding, Presentation, Structure, Subspecialty
|
||||
from . import views
|
||||
|
||||
app_name = "atlas"
|
||||
@@ -528,6 +528,11 @@ urlpatterns = [
|
||||
views.StructureAutocomplete.as_view(model=Structure, create_field="name", validate_create=True),
|
||||
name="structure-autocomplete",
|
||||
),
|
||||
path(
|
||||
"subspecialty-autocomplete",
|
||||
views.SubspecialtyAutocomplete.as_view(model=Subspecialty, create_field="name"),
|
||||
name="subspecialty-autocomplete",
|
||||
),
|
||||
path("presentation/", views.PresentationView.as_view(), name="presentation_view"),
|
||||
path(
|
||||
"presentation/<int:pk>", views.presentation_detail, name="presentation_detail"
|
||||
|
||||
@@ -1978,6 +1978,24 @@ class StructureAutocomplete(autocomplete.Select2QuerySetView):
|
||||
return qs
|
||||
|
||||
|
||||
class SubspecialtyAutocomplete(autocomplete.Select2QuerySetView):
|
||||
def get_queryset(self):
|
||||
# Don't forget to filter out results depending on the visitor !
|
||||
if not self.request.user.is_authenticated:
|
||||
return Subspecialty.objects.none()
|
||||
|
||||
qs = Subspecialty.objects.all()
|
||||
|
||||
if self.q:
|
||||
# This raises a fielderror which breaks creating a new item if not caught
|
||||
try:
|
||||
qs = qs.filter(name__icontains=self.q)
|
||||
except FieldError:
|
||||
return Subspecialty.objects.none()
|
||||
|
||||
return qs
|
||||
|
||||
|
||||
@login_required
|
||||
# @user_is_atlas_editor
|
||||
def categories_list(request):
|
||||
|
||||
+18
-1
@@ -11,6 +11,7 @@ from django.forms import (
|
||||
DateField,
|
||||
SplitDateTimeField,
|
||||
SplitDateTimeWidget,
|
||||
|
||||
)
|
||||
from django.forms import inlineformset_factory
|
||||
from atlas.models import CaseCollection
|
||||
@@ -26,10 +27,11 @@ from generic.models import (
|
||||
UserGrades,
|
||||
UserProfile,
|
||||
UserUserGroup,
|
||||
QuestionReview
|
||||
)
|
||||
|
||||
from django.contrib.admin.widgets import FilteredSelectMultiple
|
||||
from django.forms.widgets import RadioSelect, TextInput, Textarea
|
||||
from django.forms.widgets import RadioSelect, Textarea
|
||||
|
||||
from rapids.models import Rapid
|
||||
from anatomy.models import AnatomyQuestion
|
||||
@@ -832,3 +834,18 @@ class UsersAutocompleteForm(Form):
|
||||
))
|
||||
|
||||
autocomplete_register(UserAutocomplete)
|
||||
|
||||
class QuestionReviewForm(ModelForm):
|
||||
class Meta:
|
||||
model = QuestionReview
|
||||
fields = ["status", "comment"]
|
||||
widgets = {
|
||||
# Use Bootstrap "btn-check" inputs so we can style labels as button toggles
|
||||
"status": RadioSelect(attrs={"class": "btn-check", "autocomplete": "off"}),
|
||||
"comment": Textarea(attrs={"class": "form-control form-control-sm", "placeholder": "Comment (optional)", "rows": 2, "autofocus": "autofocus"}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
# Accept an optional `user` for future validation/use
|
||||
self.user = kwargs.pop("user", None)
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -0,0 +1,29 @@
|
||||
# Generated by Django 5.1.4 on 2025-10-22 19:19
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('contenttypes', '0002_remove_content_type_name'),
|
||||
('generic', '0025_cimarseriesthumbnail'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='QuestionReview',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('object_id', models.PositiveIntegerField()),
|
||||
('comment', models.TextField(blank=True)),
|
||||
('status', models.CharField(choices=[('AC', 'Accepted'), ('OD', 'Outdated'), ('ER', 'Error'), ('RJ', 'Rejected')], default='AC', max_length=2)),
|
||||
('created_on', models.DateTimeField(auto_now_add=True)),
|
||||
('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -219,6 +219,8 @@ class QuestionBase(models.Model, AuthorMixin, QuestionMixin):
|
||||
|
||||
notes = GenericRelation("generic.QuestionNote")
|
||||
|
||||
reviews = GenericRelation("generic.QuestionReview")
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
@@ -1315,6 +1317,43 @@ class NoteType(models.Model):
|
||||
def __str__(self):
|
||||
return self.note_type
|
||||
|
||||
class QuestionReview(models.Model):
|
||||
class StatusChoices(models.TextChoices):
|
||||
ACCEPTED = "AC", "Accepted"
|
||||
OUTDATED = "OD", "Outdated"
|
||||
ERROR = "ER", "Error"
|
||||
REJECTED = "RJ", "Rejected"
|
||||
IN_PROGRESS = "IP", "In Progress"
|
||||
|
||||
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
|
||||
object_id = models.PositiveIntegerField()
|
||||
question = GenericForeignKey("content_type", "object_id")
|
||||
|
||||
|
||||
author = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, blank=True, null=True
|
||||
)
|
||||
|
||||
|
||||
comment = models.TextField(blank=True)
|
||||
status = models.CharField(max_length=2, choices=StatusChoices.choices, default=StatusChoices.ACCEPTED)
|
||||
created_on = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def __str__(self):
|
||||
return "{}: {} [{}] {}".format(
|
||||
self.content_type,
|
||||
self.get_author_str(),
|
||||
self.created_on,
|
||||
self.comment,
|
||||
)
|
||||
|
||||
def get_author_str(self):
|
||||
if self.author is not None:
|
||||
return self.author.username
|
||||
else:
|
||||
return "Unknown"
|
||||
|
||||
|
||||
|
||||
class QuestionNote(models.Model):
|
||||
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
<br/>
|
||||
{% if can_edit %}
|
||||
<a href="{% url exam.get_app_name|add:':exam_overview' exam.id %}" title="View the exam">View</a>
|
||||
\ <a href="{% url exam.get_app_name|add:':exam_update' exam.id %}" title="Edit the Exam">Edit</a>
|
||||
\ <a href="{% url exam.get_app_name|add:':exam_delete' exam.id %}" title="Delete the Exam">Delete</a>
|
||||
\ <a href="{% url exam.get_app_name|add:':exam_clone' exam.id %}" title="Clone the Exam">Clone</a>
|
||||
\ <a href="{% url exam.get_app_name|add:':exam_authors' exam.id %}" title="Edit Exam Authors">Authors</a>
|
||||
\ <a href="{% url exam.get_app_name|add:':exam_markers' exam.id %}" title="Edit Exam Markers">Markers</a>
|
||||
{% endif %}
|
||||
{% if can_edit and request.user.is_superuser %}
|
||||
\
|
||||
{% endif %}
|
||||
{% if request.user.is_superuser %}
|
||||
<a href="{% url 'admin:'|add:exam.get_app_name|add:'_exam_change' exam.id %}" title="Edit the Exam using the admin interface">Admin Edit</a>
|
||||
{% endif %}
|
||||
<div class="btn-group mt-3 mt-md-0" role="group" aria-label="Actions">
|
||||
{% if can_edit %}
|
||||
<a class="btn btn-sm btn-outline-primary" href="{% url exam.get_app_name|add:':exam_overview' exam.id %}">View</a>
|
||||
<a class="btn btn-sm btn-outline-primary" href="{% url exam.get_app_name|add:':exam_update' exam.id %}">Edit</a>
|
||||
<a class="btn btn-sm btn-outline-secondary" href="{% url exam.get_app_name|add:':exam_clone' exam.id %}">Clone</a>
|
||||
<a class="btn btn-sm btn-outline-danger" href="{% url exam.get_app_name|add:':exam_delete' exam.id %}">Delete</a>
|
||||
<a class="btn btn-sm btn-outline-secondary" href="{% url exam.get_app_name|add:':exam_authors' exam.id %}">Authors</a>
|
||||
<a class="btn btn-sm btn-outline-secondary" href="{% url exam.get_app_name|add:':exam_markers' exam.id %}">Markers</a>
|
||||
{% endif %}
|
||||
|
||||
{% if request.user.is_superuser %}
|
||||
<a class="btn btn-sm btn-outline-light" href="{% url 'admin:'|add:exam.get_app_name|add:'_exam_change' exam.id %}">Admin</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -1,134 +1,199 @@
|
||||
{% include "generic/exam_link_headers.html" %}
|
||||
<h1>Exam: {{ exam }}</h1>
|
||||
|
||||
{% include 'exam_notes.html' %}
|
||||
|
||||
|
||||
{% if exam.examcollection %}
|
||||
<div>This exam is part of the collection: <a href="{{ exam.examcollection.get_absolute_url }}">{{ exam.examcollection }}</a></div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% if exam.recreate_json %}
|
||||
<div class="alert alert-warning" role="alert">
|
||||
Exam JSON may be out of date.
|
||||
|
||||
<a href="{% url exam.get_app_name|add:':exam_json_recreate' pk=exam.pk %}">Click here to force refresh</a>
|
||||
<div class="container-fluid mb-3">
|
||||
<div class="row align-items-start">
|
||||
<div class="col-12 col-md-8">
|
||||
{# Responsive: title to left, action buttons (include) to the right on md+ #}
|
||||
<div class="d-flex justify-content-between align-items-start flex-column flex-md-row mb-1">
|
||||
<div>
|
||||
<h1 class="h4 mb-1">Exam: {{ exam }}</h1>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="mt-2 mt-md-0">
|
||||
{% include "generic/exam_link_headers.html" %}
|
||||
</div>
|
||||
</div>
|
||||
{% include 'exam_notes.html' %}
|
||||
|
||||
{% if exam.examcollection %}
|
||||
<div class="mb-2">Part of collection: <a href="{{ exam.examcollection.get_absolute_url }}">{{ exam.examcollection }}</a></div>
|
||||
{% endif %}
|
||||
|
||||
{% if exam.exam_mode %}
|
||||
{% if exam.exam_open_access %}
|
||||
{% else %}
|
||||
{% if exam.recreate_json %}
|
||||
<div class="alert alert-warning" role="alert">
|
||||
Exam JSON may be out of date. <a href="{% url exam.get_app_name|add:':exam_json_recreate' pk=exam.pk %}">Force refresh</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if exam.exam_mode and not exam.exam_open_access %}
|
||||
{% if not exam.cid_user_groups.count and not exam.user_user_groups.count %}
|
||||
<div class="alert alert-danger" role="alert">
|
||||
This exam does not have any user / cid groups added.
|
||||
</div>
|
||||
{% if not candidate_count.0 and not candidate_count.1 %}
|
||||
<div class="alert alert-danger" role="alert">
|
||||
This exam does not have any candidates.
|
||||
<div class="alert alert-danger" role="alert">This exam does not have any candidates.</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if exam.archive %}
|
||||
<div class="alert alert-info" role="alert">This exam is archived. Results and submitted answers will not be available to candidates (supervisors can still view).</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-2 small text-muted">
|
||||
|
||||
This exam has <strong>{{ question_number }}</strong> questions •
|
||||
Time limit: <strong>{{ exam.time_limit }}</strong> seconds (<span title="Time per question: {% widthratio exam.time_limit question_number 1 %} seconds">per question</span>)
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if exam.archive %}
|
||||
<div class="alert alert-info" role="alert">
|
||||
This exam is archived. Results and submitted answers will not be available to candidates. They can still be viewed by supervisors.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
This exam has {{question_number}} questions.
|
||||
<span title="Time per question: {% widthratio exam.time_limit question_number 1 %} seconds">Time limit: {{exam.time_limit}} seconds [{{exam.get_time_limit}}]
|
||||
<i class="bi bi-info-circle"></i>
|
||||
</span>.
|
||||
Exam mode: {{ exam.exam_mode }}<br />
|
||||
|
||||
{% if exam.exam_mode %}
|
||||
<div class="mt-2">
|
||||
Exam mode: <strong>{{ exam.exam_mode }}</strong>
|
||||
{% if exam.exam_mode %}
|
||||
{% if exam.exam_open_access %}
|
||||
This exam is available to all registered users.<br />
|
||||
<div class="small text-muted">Available to all registered users.</div>
|
||||
{% else %}
|
||||
Cid candidates: <a href="{{exam.get_cid_edit_url}}">{{candidate_count.0}}</a>, User candidates: <a href="{{exam.get_user_edit_url}}">{{candidate_count.1}}</a><br />
|
||||
{# Improved candidate counts: use compact buttons with badges and icons; stack on xs, inline on md+ #}
|
||||
<div class="small">
|
||||
<div class="d-flex flex-column flex-md-row align-items-start align-items-md-center gap-2">
|
||||
<div class="text-muted d-none d-md-block me-md-2">Candidates:</div>
|
||||
|
||||
<a href="{{ exam.get_cid_edit_url }}" class="btn btn-sm btn-outline-primary d-inline-flex align-items-center" title="CID candidates">
|
||||
<i class="bi bi-person-badge" aria-hidden="true"></i>
|
||||
<span class="ms-1 d-none d-sm-inline">CID</span>
|
||||
<span class="badge bg-secondary ms-2" aria-label="CID candidates count">{{ candidate_count.0 }}</span>
|
||||
</a>
|
||||
|
||||
<a href="{{ exam.get_user_edit_url }}" class="btn btn-sm btn-outline-success d-inline-flex align-items-center" title="User candidates">
|
||||
<i class="bi bi-people" aria-hidden="true"></i>
|
||||
<span class="ms-1 d-none d-sm-inline">Users</span>
|
||||
<span class="badge bg-secondary ms-2" aria-label="User candidates count">{{ candidate_count.1 }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endif %}
|
||||
|
||||
Markers:
|
||||
{% for marker in exam.markers.all %}
|
||||
{{marker}}{% if not forloop.last %},{% endif %}
|
||||
{% empty %}
|
||||
<span title="Only exam authors will be able to mark.">No additional markers.</span>
|
||||
{% endfor %}
|
||||
<br/>
|
||||
|
||||
|
||||
{% if can_edit and exam.exam_mode %}
|
||||
<details class="answer-management">
|
||||
<summary>Answer management</summary>
|
||||
|
||||
<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.
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details>
|
||||
<summary>Click here to show management options</summary>
|
||||
<button title="This will clear all user answers and attempts"
|
||||
<div class="col-12 col-md-4">
|
||||
{# Cards stack vertically on mobile, align on md+ using gap utilities #}
|
||||
<div class="d-flex flex-column gap-2">
|
||||
<div class="card">
|
||||
<div class="card-body p-2">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
{% include "generic/partials/exams/exam_status.html#exam-active" %}
|
||||
{% include "generic/partials/exams/exam_status.html#publish-results" %}
|
||||
</div>
|
||||
<div class="mt-2 small text-muted">
|
||||
{% if exam.start_date %}Start: {{ exam.start_date }}{% endif %}
|
||||
{% if exam.end_date %}/ End: {{ exam.end_date }}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-body p-2">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
Open access:
|
||||
<span class="fw-bold">{{ exam.open_access }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body p-2">
|
||||
<div class="small">Markers:</div>
|
||||
<div class="mt-1">
|
||||
{% for marker in exam.markers.all %}
|
||||
<span class="d-inline-block small me-1">{{ marker }}</span>{% if not forloop.last %},{% endif %}
|
||||
{% empty %}
|
||||
<div class="small text-muted">No additional markers.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body p-2 small">
|
||||
Supervisor visible: <strong>{{ exam.results_supervisor_visible }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header p-2">
|
||||
{# Use d-block on small screens so the summary text wraps nicely; md uses flex for spacing #}
|
||||
<a class="d-block d-md-flex justify-content-between align-items-center" data-bs-toggle="collapse" data-bs-target="#open-access-bulk" role="button" aria-expanded="false" aria-controls="open-access-bulk">
|
||||
<span>Open access (questions)</span>
|
||||
<small class="text-muted">Expand to change selected questions open access status</small>
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body p-2">
|
||||
<div class="collapse" id="open-access-bulk">
|
||||
<form hx-post="{% url 'generic:generic_exam_set_open_access' %}" hx-target="#action-result" hx-swap="innerHTML" class="row g-2 align-items-center">
|
||||
<input type="hidden" name="type" value="sbas" />
|
||||
<input type="hidden" name="exam_id" value="{{ exam.pk }}" />
|
||||
<div class="col-12 small text-muted">Select questions on the list above (checkboxes) then click one of the actions below.</div>
|
||||
<div class="col-12 d-flex flex-column flex-md-row gap-2 justify-content-md-end">
|
||||
<button class="btn btn-sm btn-outline-success" type="submit" name="set_open_access" value="true" hx-include="input[name='selection']:checked">Set Open Access = True</button>
|
||||
<button class="btn btn-sm btn-outline-danger" type="submit" name="set_open_access" value="false" hx-include="input[name='selection']:checked">Set Open Access = False</button>
|
||||
<button class="btn btn-sm btn-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#open-access-bulk">Close</button>
|
||||
</div>
|
||||
</form>
|
||||
<div id="action-result" class="mt-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-3">
|
||||
<div class="col-12">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
{% if can_edit and exam.exam_mode %}
|
||||
<div class="card mb-2">
|
||||
<div class="card-body p-2">
|
||||
<details class="mb-0">
|
||||
<summary class="small">Answer management</summary>
|
||||
<div class="mt-2">
|
||||
<div class="alert alert-danger small mb-2">
|
||||
<i class="bi bi-exclamation-diamond"></i> Manage answers for this collection. These actions are permanent.
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-sm btn-danger" 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>
|
||||
hx-confirm="Are you sure you want to reset all answers? This action cannot be undone.">Reset all answers</button>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
Open access: {{ exam.open_access }}<br />
|
||||
|
||||
{% if exam.start_date %}
|
||||
Start date: {{ exam.start_date }}
|
||||
{% endif %}
|
||||
{% if exam.end_date %}
|
||||
/ End date: {{ exam.end_date }}
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% include "generic/partials/exams/exam_status.html#exam-active" %}
|
||||
{% if exam.exam_mode %}
|
||||
|
||||
{% include "generic/partials/exams/exam_status.html#publish-results" %}
|
||||
{% if exam.get_app_name == "anatomy" or exam.get_app_name == "longs" %}
|
||||
<p><a href="{% url exam.get_app_name|add:':mark_overview' pk=exam.pk %}"><button>Mark exam</button></a></p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{% if exam.examcollection %}
|
||||
Exam Collection: <a href="{{ exam.examcollection.get_absolute_url }}">{{ exam.examcollection }}</a> <br/>
|
||||
{% endif %}
|
||||
Author(s): {% for author in exam.author.all %}
|
||||
{{ author }}{% if not forloop.last %}, {% endif %}
|
||||
{% endfor %}<br/>
|
||||
<span title="If true the results from this exam will be visible to supervisors automatically. If false the trainee will have to manually approve access.">Supervisor visible: {{exam.results_supervisor_visible}}</span><br/>
|
||||
|
||||
<details>
|
||||
<summary>Extra</summary>
|
||||
Occasionally the order of questions can get out of sync. If you are exeriancing issues with the order of questions you can reset the order here.
|
||||
<button title="This will clear all user answers and attempts"
|
||||
hx-post="{% url exam.get_app_name|add:':order_questions' exam.pk %}"
|
||||
hx-swap="outerHTML"
|
||||
>Order questions</button>
|
||||
</details>
|
||||
<div class="row">
|
||||
<div class="col-12 col-md-6">
|
||||
{% if exam.examcollection %}
|
||||
<div class="small">Exam Collection: <a href="{{ exam.examcollection.get_absolute_url }}">{{ exam.examcollection }}</a></div>
|
||||
{% endif %}
|
||||
<div class="small mt-1">Author(s): {% for author in exam.author.all %}{{ author }}{% if not forloop.last %}, {% endif %}{% endfor %}</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 text-md-end small mt-2 mt-md-0">
|
||||
<span title="If true the results from this exam will be visible to supervisors automatically.">Supervisor visible: {{ exam.results_supervisor_visible }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% block css %}
|
||||
<style>
|
||||
.answer-management[open] {
|
||||
border: 1px dashed lightgrey;
|
||||
border-top: 1px dotted lightgrey;
|
||||
}
|
||||
.answer-management[open] { border: 1px dashed lightgrey; }
|
||||
</style>
|
||||
{% endblock css %}
|
||||
|
||||
|
||||
@@ -1,30 +1,61 @@
|
||||
{% load partials %}
|
||||
|
||||
{% partialdef publish-results %}
|
||||
<div id="publish-results-status" >
|
||||
Publish results: <span>{{exam.publish_results}}</span>
|
||||
<div id="publish-results-status" class="d-flex align-items-center gap-2">
|
||||
<div class="me-2 small text-muted">Publish results</div>
|
||||
<div>
|
||||
{% if exam.publish_results %}
|
||||
<button
|
||||
class="btn btn-sm btn-success"
|
||||
hx-post="{% url exam.get_app_name|add:':exam_toggle_results_published' exam.pk %}"
|
||||
hx-swap="outerHTML"
|
||||
hx-target="#publish-results-status"
|
||||
class="btn btn-sm btn-outline-primary reduce-opacity"
|
||||
>
|
||||
{% if exam.publish_results %}Unpublish Results{% else %}Publish Results{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
title="Click to unpublish results"
|
||||
aria-label="Unpublish results"
|
||||
hx-disabled-elt="this"
|
||||
|
||||
>Published</button>
|
||||
{% else %}
|
||||
<button
|
||||
class="btn btn-sm btn-secondary"
|
||||
hx-post="{% url exam.get_app_name|add:':exam_toggle_results_published' exam.pk %}"
|
||||
hx-swap="outerHTML"
|
||||
hx-target="#publish-results-status"
|
||||
title="Click to publish results"
|
||||
aria-label="Publish results"
|
||||
hx-disabled-elt="this"
|
||||
>Unpublished</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endpartialdef publish-results %}
|
||||
|
||||
|
||||
{% partialdef exam-active %}
|
||||
<div id="exam-active-status">
|
||||
Exam active: <span>{{exam.active}}</span>
|
||||
<div id="exam-active-status" class="d-flex align-items-center gap-2">
|
||||
<div class="me-2 small text-muted">Exam active</div>
|
||||
<div>
|
||||
{% if exam.active %}
|
||||
<button
|
||||
class="btn btn-sm btn-success"
|
||||
hx-post="{% url exam.get_app_name|add:':exam_toggle_active' exam.pk %}"
|
||||
hx-swap="outerHTML"
|
||||
hx-target="#exam-active-status"
|
||||
class="btn btn-sm btn-outline-primary reduce-opacity"
|
||||
>
|
||||
{% if exam.active %}Deactivate Exam{% else %}Activate Exam{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
title="Click to deactivate this exam"
|
||||
aria-label="Deactivate exam"
|
||||
hx-disabled-elt="this"
|
||||
>Active</button>
|
||||
{% else %}
|
||||
<button
|
||||
class="btn btn-sm btn-secondary"
|
||||
hx-post="{% url exam.get_app_name|add:':exam_toggle_active' exam.pk %}"
|
||||
hx-swap="outerHTML"
|
||||
hx-target="#exam-active-status"
|
||||
title="Click to activate this exam"
|
||||
aria-label="Activate exam"
|
||||
hx-disabled-elt="this"
|
||||
>Inactive</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endpartialdef exam-active %}
|
||||
@@ -0,0 +1,12 @@
|
||||
<div id="question-review-block" class="mb-3">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<div class="small text-muted">Latest review</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="badge bg-info text-white">{{ review.get_status_display }}</span>
|
||||
<div class="small">{{ review.comment }}</div>
|
||||
<div class="text-muted small ms-2">by {{ review.get_author_str }} on {{ review.created_on }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,36 @@
|
||||
<form method="post"
|
||||
hx-post="{% url app_name|add:':question_set_review' question.pk %}?edit=1"
|
||||
hx-target="#question-review-block"
|
||||
hx-swap="innerHTML"
|
||||
class="w-100">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="row g-2 align-items-start">
|
||||
<div class="col-auto">
|
||||
<label class="form-label mb-1">Status</label>
|
||||
<div>
|
||||
{% with current_status=form.status.value %}
|
||||
{% for radio in form.status %}
|
||||
{% comment %} Render colored button for each choice by checking value {% endcomment %}
|
||||
<label class="btn btn-outline-primary me-1 mb-1{% if radio.choice_value == current_status %} active{% endif %}" for="{{ radio.id_for_label }}">{{ radio.choice_label }}</label>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
{% if form.status.errors %}
|
||||
<div class="invalid-feedback d-block">{{ form.status.errors|join:", " }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col">
|
||||
<label for="{{ form.comment.id_for_label }}" class="form-label mb-1">Comment</label>
|
||||
{{ form.comment }}
|
||||
{% if form.comment.errors %}
|
||||
<div class="invalid-feedback d-block">{{ form.comment.errors|join:", " }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-auto d-flex align-items-end">
|
||||
<button class="btn btn-primary" type="submit">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@@ -0,0 +1,27 @@
|
||||
<div id="question-reviews-list" class="mb-3">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div class="small text-muted">Review history</div>
|
||||
<div>
|
||||
<button class="btn btn-sm btn-outline-secondary" hx-get="{{ request.path }}" hx-target="#question-reviews-list" hx-swap="outerHTML">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
{% if reviews %}
|
||||
<ul class="list-unstyled">
|
||||
{% for r in reviews %}
|
||||
<li class="mb-2">
|
||||
<div class="d-flex align-items-start gap-2">
|
||||
<span class="badge bg-info text-white">{{ r.get_status_display }}</span>
|
||||
<div>
|
||||
<div class="small">{{ r.comment|linebreaksbr }}</div>
|
||||
<div class="text-muted small">by {{ r.get_author_str }} on {{ r.created_on }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<div class="small text-muted">No reviews yet</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -34,6 +34,16 @@ urlpatterns = [
|
||||
views.generic_exam_json_edit,
|
||||
name="generic_exam_json_edit",
|
||||
),
|
||||
path(
|
||||
"generic_exam_htmx",
|
||||
views.generic_exam_htmx,
|
||||
name="generic_exam_htmx",
|
||||
),
|
||||
path(
|
||||
"generic_exam_set_open_access",
|
||||
views.generic_exam_set_open_access,
|
||||
name="generic_exam_set_open_access",
|
||||
),
|
||||
path("bulk_delete_questions/", views.bulk_delete_questions, name="bulk_delete_questions"),
|
||||
path(
|
||||
"examination-autocomplete",
|
||||
@@ -266,6 +276,16 @@ def generic_view_urls(generic_views: GenericViewBase):
|
||||
path(
|
||||
"question/<int:pk>/", generic_views.question_detail, name="question_detail"
|
||||
),
|
||||
path(
|
||||
"question/<int:pk>/set_review",
|
||||
generic_views.question_set_review,
|
||||
name="question_set_review",
|
||||
),
|
||||
path(
|
||||
"question/<int:pk>/reviews",
|
||||
generic_views.question_reviews_list,
|
||||
name="question_reviews",
|
||||
),
|
||||
path("question/<int:pk>/thumbnail/fail", generic_views.question_thumbnail_fail, name="series_thumbnail_fail"),
|
||||
path("question/<int:pk>/thumbnail", generic_views.question_thumbnail, name="series_thumbnail"),
|
||||
path(
|
||||
|
||||
@@ -433,6 +433,22 @@ def get_examination_id(request):
|
||||
# Generic view to dispatch to indivuals app views
|
||||
@login_required
|
||||
def generic_exam_json_edit(request):
|
||||
# GET: return a small fragment with an exam selector for the given type
|
||||
if request.method == "GET":
|
||||
question_type = request.GET.get("type")
|
||||
if not question_type:
|
||||
return JsonResponse({"status": "error", "message": "missing type"}, status=400)
|
||||
try:
|
||||
Exam = get_exam_model_from_app_name(question_type)
|
||||
except Exception:
|
||||
return JsonResponse({"status": "error", "message": "unknown type"}, status=400)
|
||||
|
||||
# Limit to exams that are not archived and are exam_mode
|
||||
exams = Exam.objects.filter(archive=False, exam_mode=True).order_by("name")[:200]
|
||||
return render(request, "generic/partials/exam_select_fragment.html", {"exams": exams, "type": question_type})
|
||||
|
||||
# Support POST additions via either an explicit JSON 'add_exam_questions' or
|
||||
# via a list of checkboxes named 'selection' sent by HTMX from the question list.
|
||||
if request.method == "POST":
|
||||
question_type = request.POST.get("type")
|
||||
|
||||
@@ -468,6 +484,16 @@ def generic_exam_json_edit(request):
|
||||
|
||||
if "add_exam_questions" in request.POST:
|
||||
question_ids = json.loads(request.POST.get("add_exam_questions"))
|
||||
else:
|
||||
# Support HTMX flow where checkboxes named 'selection' are posted
|
||||
# (multiple values). Convert to list of ints.
|
||||
sel = request.POST.getlist("selection") or request.POST.getlist("selection[]")
|
||||
question_ids = []
|
||||
for s in sel:
|
||||
try:
|
||||
question_ids.append(int(s))
|
||||
except Exception:
|
||||
continue
|
||||
# question_objects = Question.objects.filter(pk__in=question_ids)
|
||||
|
||||
add_count = 0
|
||||
@@ -541,6 +567,139 @@ def generic_exam_json_edit(request):
|
||||
return JsonResponse(data, status=400)
|
||||
|
||||
|
||||
@login_required
|
||||
def generic_exam_htmx(request):
|
||||
"""HTMX-specific endpoint for selecting an exam and adding selected questions.
|
||||
|
||||
GET: returns the exam selection fragment (same as earlier partial)
|
||||
POST: expects 'type' and checkbox list 'selection' (or selection[]) and 'exam_id'
|
||||
adds selected question ids to the chosen exam and returns a small HTML fragment
|
||||
or JSON suitable for HTMX swapping.
|
||||
"""
|
||||
if request.method == "GET":
|
||||
question_type = request.GET.get("type")
|
||||
if not question_type:
|
||||
return HttpResponse("Missing type", status=400)
|
||||
try:
|
||||
Exam = get_exam_model_from_app_name(question_type)
|
||||
except Exception:
|
||||
return HttpResponse("Unknown type", status=400)
|
||||
|
||||
# For non-superusers, only show exams they can edit or that they authored
|
||||
if request.user.is_superuser:
|
||||
exams = Exam.objects.filter(archive=False, exam_mode=True).order_by("name")
|
||||
else:
|
||||
# authors or markers or assigned exams
|
||||
exams = (
|
||||
Exam.objects.filter(author__id=request.user.id)
|
||||
| Exam.objects.filter(markers__id=request.user.id)
|
||||
)
|
||||
exams = exams.filter(archive=False).order_by("name")
|
||||
|
||||
return render(request, "generic/partials/exam_select_fragment.html", {"exams": exams, "type": question_type})
|
||||
|
||||
if request.method == "POST":
|
||||
logger.debug(f"generic_exam_htmx POST: {request.POST}")
|
||||
question_type = request.POST.get("type")
|
||||
if not question_type:
|
||||
return JsonResponse({"status": "error", "message": "missing type"}, status=400)
|
||||
|
||||
try:
|
||||
Exam = get_exam_model_from_app_name(question_type)
|
||||
except Exception:
|
||||
return JsonResponse({"status": "error", "message": "unknown type"}, status=400)
|
||||
|
||||
exam = get_object_or_404(Exam, pk=request.POST.get("exam_id"))
|
||||
|
||||
# read checkbox selections
|
||||
sel = request.POST.getlist("selection") or request.POST.getlist("selection[]")
|
||||
question_ids = []
|
||||
for s in sel:
|
||||
try:
|
||||
question_ids.append(int(s))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
add_count = 0
|
||||
for i in question_ids:
|
||||
if not exam.exam_questions.filter(pk=i).exists():
|
||||
add_count += 1
|
||||
exam.exam_questions.add(i)
|
||||
|
||||
if add_count > 0:
|
||||
exam.save()
|
||||
|
||||
# return a small HTMX-friendly fragment summarising the result
|
||||
return render(request, "generic/partials/exam_select_result.html", {"added": add_count, "exam": exam})
|
||||
|
||||
return HttpResponse(status=405)
|
||||
|
||||
|
||||
@login_required
|
||||
def generic_exam_set_open_access(request):
|
||||
"""HTMX endpoint to set open_access on questions in an exam.
|
||||
|
||||
POST params:
|
||||
- type: app name (sbas, rapids, anatomy, physics, shorts, longs)
|
||||
- exam_id: id of the exam
|
||||
- set_open_access: 'true' or 'false'
|
||||
- selection / selection[]: optional list of question ids to apply to (if omitted apply to all)
|
||||
|
||||
Only questions that request.user can edit (question.can_edit(user)) or superusers will be modified.
|
||||
Returns a small HTML fragment summarising the count of changed questions.
|
||||
"""
|
||||
if request.method != "POST":
|
||||
return HttpResponse(status=405)
|
||||
|
||||
question_type = request.POST.get("type")
|
||||
if not question_type:
|
||||
return JsonResponse({"status": "error", "message": "missing type"}, status=400)
|
||||
|
||||
try:
|
||||
Exam = get_exam_model_from_app_name(question_type)
|
||||
except Exception:
|
||||
return JsonResponse({"status": "error", "message": "unknown type"}, status=400)
|
||||
|
||||
exam = get_object_or_404(Exam, pk=request.POST.get("exam_id"))
|
||||
|
||||
# permission to edit exam required
|
||||
# Many exam edits are limited to exam authors / checkers; reuse existing check where possible
|
||||
# For now require the user to have edit access (ExamViews.check_user_edit_access equivalent)
|
||||
# We'll do a lightweight check: if user is superuser OR user in exam.get_author_objects()
|
||||
if not (request.user.is_superuser or request.user in exam.get_author_objects()):
|
||||
return HttpResponse(format_html('<div class="alert alert-danger">Permission denied</div>'))
|
||||
|
||||
set_val = True if request.POST.get("set_open_access") == "true" else False
|
||||
|
||||
sel = request.POST.getlist("selection") or request.POST.getlist("selection[]")
|
||||
if sel:
|
||||
try:
|
||||
ids = [int(s) for s in sel]
|
||||
except Exception:
|
||||
ids = []
|
||||
qs = exam.exam_questions.filter(pk__in=ids)
|
||||
else:
|
||||
qs = exam.exam_questions.all()
|
||||
|
||||
changed = 0
|
||||
for q in qs:
|
||||
try:
|
||||
# prefer per-question permission check if available
|
||||
can_edit = request.user.is_superuser or getattr(q, 'can_edit', lambda u: False)(request.user)
|
||||
except Exception:
|
||||
can_edit = request.user.is_superuser
|
||||
|
||||
if not can_edit:
|
||||
continue
|
||||
|
||||
if q.open_access != set_val:
|
||||
q.open_access = set_val
|
||||
q.save()
|
||||
changed += 1
|
||||
|
||||
return render(request, "generic/partials/set_open_access_result.html", {"changed": changed, "exam": exam, "state": set_val})
|
||||
|
||||
|
||||
class ExamViews(View, LoginRequiredMixin):
|
||||
def __init__(
|
||||
self,
|
||||
@@ -1829,6 +1988,7 @@ class ExamViews(View, LoginRequiredMixin):
|
||||
"view_feedback": view_feedback,
|
||||
"can_edit": can_edit,
|
||||
"remote_url": settings.REMOTE_URL,
|
||||
"app_name": self.app_name,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -2788,6 +2948,85 @@ class GenericViewBase:
|
||||
question_json = question.get_question_json(based=False, feedback=True)
|
||||
return JsonResponse(question_json)
|
||||
|
||||
def question_set_review(self, request, pk):
|
||||
"""HTMX endpoint to get/set the latest QuestionReview for a question.
|
||||
|
||||
GET: renders the current review block or the form (if ?edit=1)
|
||||
POST: creates a new QuestionReview and returns the rendered block
|
||||
"""
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from generic.models import QuestionReview
|
||||
from .forms import QuestionReviewForm
|
||||
|
||||
logger.debug("question_set_review called")
|
||||
|
||||
question = get_object_or_404(self.question_object, pk=pk)
|
||||
|
||||
ct = ContentType.objects.get_for_model(question)
|
||||
latest = (
|
||||
QuestionReview.objects.filter(content_type=ct, object_id=question.pk)
|
||||
.order_by("-created_on").first()
|
||||
)
|
||||
|
||||
# If GET and edit requested, return the form. If `new` is set, render with no latest so the
|
||||
# form is blank for creating a fresh review.
|
||||
if request.method == "GET" and request.GET.get("edit"):
|
||||
if request.GET.get("new"):
|
||||
form = QuestionReviewForm()
|
||||
return render(request, "generic/partials/question_review_form.html", {"question": question, "latest": None, "form": form, "app_name": self.app_name})
|
||||
# Prefill the form with the latest review values (but we'll create a new review on POST)
|
||||
if latest:
|
||||
form = QuestionReviewForm(initial={"status": latest.status, "comment": latest.comment})
|
||||
else:
|
||||
form = QuestionReviewForm()
|
||||
return render(request, "generic/partials/question_review_form.html", {"question": question, "latest": latest, "form": form, "app_name": self.app_name})
|
||||
|
||||
if request.method == "POST":
|
||||
# Require login to post a review
|
||||
if not request.user.is_authenticated:
|
||||
return HttpResponse(status=403)
|
||||
|
||||
form = QuestionReviewForm(request.POST, user=request.user)
|
||||
if not form.is_valid():
|
||||
# Render the form back with errors
|
||||
return render(request, "generic/partials/question_review_form.html", {"question": question, "latest": latest, "form": form, "app_name": self.app_name})
|
||||
|
||||
review = QuestionReview.objects.create(
|
||||
content_type=ct,
|
||||
object_id=question.pk,
|
||||
author=request.user,
|
||||
status=form.cleaned_data["status"],
|
||||
comment=form.cleaned_data.get("comment", ""),
|
||||
)
|
||||
|
||||
return render(request, "generic/partials/question_review_block.html", {"review": review, "question": question, "app_name": self.app_name})
|
||||
|
||||
# Default: render the block (latest if exists, otherwise form)
|
||||
if latest:
|
||||
return render(request, "generic/partials/question_review_block.html", {"review": latest, "question": question, "app_name": self.app_name})
|
||||
else:
|
||||
# Ensure a form is available for the template
|
||||
if latest:
|
||||
form = QuestionReviewForm(initial={"status": latest.status, "comment": latest.comment})
|
||||
else:
|
||||
form = QuestionReviewForm()
|
||||
return render(request, "generic/partials/question_review_form.html", {"question": question, "latest": latest, "form": form, "app_name": self.app_name})
|
||||
|
||||
def question_reviews_list(self, request, pk):
|
||||
"""Return a partial listing all QuestionReview instances for a question."""
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from generic.models import QuestionReview
|
||||
|
||||
question = get_object_or_404(self.question_object, pk=pk)
|
||||
|
||||
ct = ContentType.objects.get_for_model(question)
|
||||
reviews = (
|
||||
QuestionReview.objects.filter(content_type=ct, object_id=question.pk)
|
||||
.order_by("-created_on")
|
||||
)
|
||||
|
||||
return render(request, "generic/partials/question_reviews_list.html", {"question": question, "reviews": reviews, "app_name": self.app_name})
|
||||
|
||||
@method_decorator(user_passes_test(lambda u: u.is_superuser))
|
||||
def user_answer_delete_multiple(self, request):
|
||||
print(request.POST["answer_ids"])
|
||||
@@ -2840,6 +3079,7 @@ class GenericViewBase:
|
||||
"view_feedback": view_feedback,
|
||||
"remote_url": settings.REMOTE_URL,
|
||||
"can_edit": question.can_edit(request.user),
|
||||
"app_name": self.app_name,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -16,7 +16,9 @@
|
||||
{% render_table table %}
|
||||
|
||||
<button id="button-select-add-exam"
|
||||
data-exam_json_edit_url="{% url 'generic:generic_exam_json_edit' %}"
|
||||
hx-get="{% url 'generic:generic_exam_htmx' %}?type=rapid"
|
||||
hx-target="#action-result"
|
||||
hx-swap="innerHTML"
|
||||
data-exam_list_url="{% url 'api-1:rapid_user_exams' %}"
|
||||
data-type="rapid" data-csrf="{{ csrf_token}}">Add selected questions to
|
||||
exam</button>
|
||||
|
||||
+50
-2
@@ -1,6 +1,9 @@
|
||||
import django_filters
|
||||
from dal import autocomplete
|
||||
|
||||
from .models import Category, Question, UserAnswer, Exam
|
||||
from django.db.models import Q
|
||||
from atlas.models import Condition, Structure, Finding, Presentation, Subspecialty
|
||||
|
||||
#from generic.filters import UserAnswerFilter
|
||||
from django.contrib.auth.models import User
|
||||
@@ -30,12 +33,42 @@ def get_authors(request):
|
||||
|
||||
|
||||
class QuestionFilter(django_filters.FilterSet):
|
||||
# simple text searches
|
||||
title = django_filters.CharFilter(field_name='title', lookup_expr='icontains', label='Title')
|
||||
stem = django_filters.CharFilter(field_name='stem', lookup_expr='icontains', label='Stem')
|
||||
# combined quick search across title and stem
|
||||
q = django_filters.CharFilter(method='filter_q', label='Search')
|
||||
exams = django_filters.ModelMultipleChoiceFilter(queryset=get_exams, null_label="No exam")
|
||||
author = django_filters.ModelMultipleChoiceFilter(queryset=get_authors, null_label="No author")
|
||||
category = django_filters.ModelChoiceFilter(queryset=Category.objects.all(),null_label="Uncategorised")
|
||||
category = django_filters.ModelMultipleChoiceFilter(queryset=Category.objects.all(),null_label="Uncategorised")
|
||||
subspecialty = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Subspecialty.objects.all(),
|
||||
widget=autocomplete.ModelSelect2Multiple(url='atlas:subspecialty-autocomplete'),
|
||||
label='Subspecialty',
|
||||
)
|
||||
condition = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Condition.objects.all(),
|
||||
widget=autocomplete.ModelSelect2Multiple(url='atlas:condition-autocomplete'),
|
||||
label='Condition',
|
||||
)
|
||||
structure = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Structure.objects.all(),
|
||||
widget=autocomplete.ModelSelect2Multiple(url='atlas:structure-autocomplete'),
|
||||
label='Structure',
|
||||
)
|
||||
finding = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Finding.objects.all(),
|
||||
widget=autocomplete.ModelSelect2Multiple(url='atlas:finding-autocomplete'),
|
||||
label='Finding',
|
||||
)
|
||||
presentation = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Presentation.objects.all(),
|
||||
widget=autocomplete.ModelSelect2Multiple(url='atlas:presentation-autocomplete'),
|
||||
label='Presentation',
|
||||
)
|
||||
class Meta:
|
||||
model = Question
|
||||
fields = ("exams", "created_date")
|
||||
fields = ("exams", "created_date", "condition", "structure", "finding", "presentation", "category", "subspecialty", "title", "stem", "q")
|
||||
|
||||
def __init__(self, data=None, queryset=None, prefix=None, strict=None, user=None, request=None):
|
||||
if not request.user.groups.filter(name="sba_checker").exists():
|
||||
@@ -43,6 +76,21 @@ class QuestionFilter(django_filters.FilterSet):
|
||||
super(QuestionFilter, self).__init__(data=data, queryset=queryset, prefix=prefix, request=request)
|
||||
pass
|
||||
|
||||
def filter_q(self, queryset, name, value):
|
||||
# search both title and stem
|
||||
if not value:
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(title__icontains=value) |
|
||||
Q(stem__icontains=value) |
|
||||
Q(a_answer__icontains=value) |
|
||||
Q(b_answer__icontains=value) |
|
||||
Q(c_answer__icontains=value) |
|
||||
Q(d_answer__icontains=value) |
|
||||
Q(e_answer__icontains=value) |
|
||||
Q(best_answer__icontains=value)
|
||||
)
|
||||
|
||||
class UserAnswerFilter(django_filters.FilterSet):
|
||||
class Meta:
|
||||
model= UserAnswer
|
||||
|
||||
@@ -148,6 +148,7 @@ class QuestionForm(ModelForm):
|
||||
"condition",
|
||||
"presentation",
|
||||
"subspecialty",
|
||||
"open_access",
|
||||
]
|
||||
|
||||
widgets = {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.4 on 2025-10-20 19:46
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('sbas', '0020_question_sources'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='question',
|
||||
name='open_access',
|
||||
field=models.BooleanField(default=True, help_text='If set, this question is available to all users for use in their exams.'),
|
||||
),
|
||||
]
|
||||
+45
-5
@@ -4,6 +4,7 @@ from django.utils import timezone
|
||||
from django.core.files.storage import FileSystemStorage
|
||||
from django.conf import settings
|
||||
from django.utils.html import format_html
|
||||
import re
|
||||
|
||||
from django.urls import reverse
|
||||
|
||||
@@ -25,6 +26,7 @@ class Category(models.Model):
|
||||
return self.category
|
||||
|
||||
|
||||
@reversion.register
|
||||
class Question(QuestionBase):
|
||||
title = models.CharField(max_length=200, help_text="Short title for question", blank=True, null=True)
|
||||
stem = models.TextField(
|
||||
@@ -106,22 +108,60 @@ class Question(QuestionBase):
|
||||
presentation = models.ManyToManyField(Presentation, blank=True, related_name="sbas_questions")
|
||||
subspecialty = models.ManyToManyField(Subspecialty, blank=True, related_name="sbas_questions")
|
||||
|
||||
open_access = models.BooleanField(
|
||||
default=True, help_text="If set, this question is available to all users for use in their exams."
|
||||
)
|
||||
|
||||
#notes = GenericRelation(QuestionNote)
|
||||
|
||||
def __str__(self):
|
||||
return self.stem
|
||||
|
||||
def clean(self):
|
||||
# normalize whitespace and strip surrounding <p>...</p> wrappers
|
||||
try:
|
||||
super().clean()
|
||||
except Exception:
|
||||
# If parent has no clean or it fails, ignore and continue
|
||||
pass
|
||||
|
||||
def _strip_outer_p(text):
|
||||
if not text:
|
||||
return text
|
||||
t = text.strip()
|
||||
# match <p> with optional attributes, e.g. <p>, <p class="...">, etc.
|
||||
m = re.match(r"^<p[^>]*>(.*)</p>$", t, flags=re.IGNORECASE | re.DOTALL)
|
||||
if m:
|
||||
return m.group(1).strip()
|
||||
return t
|
||||
|
||||
# Answers
|
||||
if self.a_answer:
|
||||
self.a_answer = self.a_answer.strip()
|
||||
self.a_answer = _strip_outer_p(self.a_answer)
|
||||
if self.b_answer:
|
||||
self.b_answer = self.b_answer.strip()
|
||||
self.b_answer = _strip_outer_p(self.b_answer)
|
||||
if self.c_answer:
|
||||
self.c_answer = self.c_answer.strip()
|
||||
self.c_answer = _strip_outer_p(self.c_answer)
|
||||
if self.d_answer:
|
||||
self.d_answer = self.d_answer.strip()
|
||||
self.d_answer = _strip_outer_p(self.d_answer)
|
||||
if self.e_answer:
|
||||
self.e_answer = self.e_answer.strip()
|
||||
self.e_answer = _strip_outer_p(self.e_answer)
|
||||
|
||||
# Per-answer feedbacks
|
||||
if hasattr(self, 'a_feedback') and self.a_feedback:
|
||||
self.a_feedback = _strip_outer_p(self.a_feedback)
|
||||
if hasattr(self, 'b_feedback') and self.b_feedback:
|
||||
self.b_feedback = _strip_outer_p(self.b_feedback)
|
||||
if hasattr(self, 'c_feedback') and self.c_feedback:
|
||||
self.c_feedback = _strip_outer_p(self.c_feedback)
|
||||
if hasattr(self, 'd_feedback') and self.d_feedback:
|
||||
self.d_feedback = _strip_outer_p(self.d_feedback)
|
||||
if hasattr(self, 'e_feedback') and self.e_feedback:
|
||||
self.e_feedback = _strip_outer_p(self.e_feedback)
|
||||
|
||||
# General feedback
|
||||
if hasattr(self, 'feedback') and self.feedback:
|
||||
self.feedback = _strip_outer_p(self.feedback)
|
||||
|
||||
def get_app_name(self):
|
||||
return "sbas"
|
||||
|
||||
+19
-1
@@ -8,11 +8,19 @@ from django.utils.html import format_html
|
||||
|
||||
|
||||
class QuestionTable(tables.Table):
|
||||
def get_view_cell(record):
|
||||
if record.open_access:
|
||||
return format_html(f"""<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-opencollective" viewBox="0 0 16 16">
|
||||
<path fill-opacity=".4" d="M12.995 8.195c0 .937-.312 1.912-.78 2.693l1.99 1.99c.976-1.327 1.6-2.966 1.6-4.683 0-1.795-.624-3.434-1.561-4.76l-2.068 2.028c.468.781.78 1.679.78 2.732h.04Z"/>
|
||||
<path d="M8 13.151a4.995 4.995 0 1 1 0-9.99c1.015 0 1.951.273 2.732.82l1.95-2.03a7.805 7.805 0 1 0 .04 12.449l-1.951-2.03a5.072 5.072 0 0 1-2.732.781z"/>
|
||||
</svg> View""")
|
||||
else:
|
||||
return f'View'
|
||||
#edit = tables.LinkColumn(
|
||||
# "sbas:sbas_question_update", text="Edit", args=[A("pk")], orderable=False
|
||||
#)
|
||||
view = tables.LinkColumn(
|
||||
"sbas:question_detail", text="View", args=[A("pk")], orderable=False
|
||||
"sbas:question_detail", text=get_view_cell, args=[A("pk")], orderable=False
|
||||
)
|
||||
#delete = tables.LinkColumn(
|
||||
# "sbas:question_delete", text="Delete", args=[A("pk")], orderable=False
|
||||
@@ -69,6 +77,16 @@ class QuestionTable(tables.Table):
|
||||
return format_html("<h5>{}</h5>{}", title, mark_safe(value))
|
||||
return mark_safe(value)
|
||||
|
||||
def render_best_answer(self, value, record=None, **kwargs):
|
||||
"""Show only the capitalised first character of the best_answer."""
|
||||
if value is None:
|
||||
return ""
|
||||
s = str(value).strip()
|
||||
if not s:
|
||||
return ""
|
||||
return s[0].upper()
|
||||
|
||||
|
||||
|
||||
class UserAnswerTable(tables.Table):
|
||||
select = tables.CheckBoxColumn(accessor=("pk"))
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block navigation %}
|
||||
<nav class="navbar navbar-expand-lg navbar-dark submenu">
|
||||
<nav class="navbar navbar-expand-lg navbar-dark submenu">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="{% url 'sbas:index' %}">SBAs</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||
@@ -17,8 +17,14 @@
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'sbas:exam_list' %}"><i class="bi bi-mortarboard"></i> Exams</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'sbas:question_view' %}"><i class="bi bi-question-circle"></i> Questions</a>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" id="sbasQuestionsDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<i class="bi bi-question-circle"></i> Questions
|
||||
</a>
|
||||
<ul class="dropdown-menu" aria-labelledby="sbasQuestionsDropdown">
|
||||
<li><a class="dropdown-item" href="{% url 'sbas:question_view' %}"><i class="bi bi-list-ul"></i> All Questions</a></li>
|
||||
<!-- Additional items can be added here -->
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" data-bs-toggle="dropdown" href="#" role="button" aria-expanded="false"><i class="bi bi-file-earmark-plus"></i> Create</a>
|
||||
@@ -27,9 +33,9 @@
|
||||
<li><a class="dropdown-item" href="{% url 'sbas:question_create' %}" title="Create a new question"><i class="bi bi-question-circle"></i> Question</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item-dropdown">
|
||||
<a class="nav-link dropdown-toggle" data-bs-toggle="dropdown" href="#" role="button" aria-expanded="false"><i class="bi bi-gear"></i> LLM</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" id="sbasLLMDropdown" data-bs-toggle="dropdown" href="#" role="button" aria-expanded="false"><i class="bi bi-gear"></i> LLM</a>
|
||||
<ul class="dropdown-menu" aria-labelledby="sbasLLMDropdown">
|
||||
<li><a class="dropdown-item" href="{% url 'sbas:llm_prompt_view' %}" title="Generate questions using LLM"><i class="bi bi-robot"></i> LLM prompt</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'sbas:import_llm_questions' %}" title="Import questions using LLM"><i class="bi bi-upload"></i> Import Questions</a></li>
|
||||
</ul>
|
||||
@@ -44,7 +50,7 @@
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -8,33 +8,61 @@
|
||||
{% block content %}
|
||||
|
||||
{% load thumbnail %}
|
||||
<div class="sbas">
|
||||
<div class="container sbas">
|
||||
<div class="row mb-3">
|
||||
<div class="col-12">
|
||||
<p class="lead">This exam will be available to take <a href="{% url 'sbas:exam_start' pk=exam.pk %}">here</a> (when active).</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
This exam will be available to take <a href="{% url 'sbas:exam_start' pk=exam.pk %}">here</a> (when active).
|
||||
<h3 class="mb-3">Questions in this Exam</h3>
|
||||
|
||||
{% autoescape off %}
|
||||
<ol id="full-question-list-sba">
|
||||
<div class="row g-3">
|
||||
{% for question in questions.all %}
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<div class="flex-grow-1">
|
||||
<span><span class="badge bg-secondary text-white me-2">Q{{ forloop.counter }}</span>: {{ question.title }}</span>
|
||||
<div class="question-stem">{{ question.stem|safe }}</div>
|
||||
</div>
|
||||
<div class="ms-3 text-end">
|
||||
<div class="small text-muted">Category: {{ question.category }}</div>
|
||||
<div class="mt-1">
|
||||
<a class="btn btn-sm btn-outline-primary" href="{% url 'sbas:question_detail' pk=question.pk %}">View</a>
|
||||
<a class="btn btn-sm btn-outline-secondary" href="{% url 'admin:sbas_question_change' question.id %}">Edit</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<li>
|
||||
<span>
|
||||
{{ question.stem|safe }}
|
||||
<ol type="a" class="abcde">
|
||||
<div class="row">
|
||||
<div class="col-12 col-md-9">
|
||||
<ol type="a" class="abcde mb-0">
|
||||
<li> {{ question.a_answer|safe }} </li>
|
||||
<li> {{ question.b_answer|safe }} </li>
|
||||
<li> {{ question.c_answer|safe }} </li>
|
||||
<li> {{ question.d_answer|safe }} </li>
|
||||
<li> {{ question.e_answer|safe }} </li> Best answer : {{question.best_answer}} ({{question.get_correct_answer_stripped}})
|
||||
<li> {{ question.e_answer|safe }} </li>
|
||||
</ol>
|
||||
</span>
|
||||
Category: {{ question.category }}, <a href="{% url 'sbas:question_detail' pk=question.pk %}">View</a> <a
|
||||
href="{% url 'admin:sbas_question_change' question.id %}">Edit</a>
|
||||
|
||||
</li>
|
||||
</div>
|
||||
<div class="col-12 col-md-3 text-md-end mt-2 mt-md-0">
|
||||
<div class="small text-muted">Best answer</div>
|
||||
<div class="mt-1">
|
||||
{% if question.best_answer %}
|
||||
<span class="badge bg-primary fs-5">{{ question.best_answer|stringformat:'s'|slice:":1"|upper }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">-</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="small text-muted mt-2">({{ question.get_correct_answer_stripped }})</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
{% endautoescape %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<script type="text/javascript">
|
||||
$(document).ready(function () {
|
||||
|
||||
@@ -2,51 +2,83 @@
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="session_key" value="{{ session_key }}">
|
||||
{% for item in items %}
|
||||
<div class="card mb-2">
|
||||
<div class="card mb-2" id="import-item-{{ item.index }}">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Question {{ item.index|add:1 }}</h5>
|
||||
{% if item.payload %}
|
||||
<p><strong>Title:</strong> {{ item.payload.title|default:'(No title)'|safe }}</p>
|
||||
<p><strong>Stem:</strong> {{ item.payload.stem|default:''|safe }}</p>
|
||||
<p><strong>Answers:</strong>
|
||||
<ul>
|
||||
<li>A: {{ item.payload.a_answer|default:''|safe }}</li>
|
||||
{% if item.payload.a_feedback %}
|
||||
<p><strong>A Feedback:</strong> {{ item.payload.a_feedback|default:''|safe }}</p>
|
||||
{% endif %}
|
||||
<li>B: {{ item.payload.b_answer|default:''|safe }}</li>
|
||||
{% if item.payload.b_feedback %}
|
||||
<p><strong>B Feedback:</strong> {{ item.payload.b_feedback|default:''|safe }}</p>
|
||||
{% endif %}
|
||||
<li>C: {{ item.payload.c_answer|default:''|safe }}</li>
|
||||
{% if item.payload.c_feedback %}
|
||||
<p><strong>C Feedback:</strong> {{ item.payload.c_feedback|default:''|safe }}</p>
|
||||
{% endif %}
|
||||
<li>D: {{ item.payload.d_answer|default:''|safe }}</li>
|
||||
{% if item.payload.d_feedback %}
|
||||
<p><strong>D Feedback:</strong> {{ item.payload.d_feedback|default:''|safe }}</p>
|
||||
{% endif %}
|
||||
<li>E: {{ item.payload.e_answer|default:''|safe }}</li>
|
||||
{% if item.payload.e_feedback %}
|
||||
<p><strong>E Feedback:</strong> {{ item.payload.e_feedback|default:''|safe }}</p>
|
||||
{% endif %}
|
||||
|
||||
</ul>
|
||||
<p><strong>Best Answer:</strong> {{ item.payload.best_answer|default:'' }}
|
||||
</p>
|
||||
<p><strong>Feedback:</strong> {{ item.payload.feedback|default:''|safe }}</p>
|
||||
<p><strong>Sources:</strong>
|
||||
{% if item.payload.sources %}
|
||||
<ul>
|
||||
{% for source in item.payload.sources %}
|
||||
<li>{{ source }}</li>
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<h5 class="card-title mb-0">Question {{ item.index|add:1 }}</h5>
|
||||
<div>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary ms-2" data-item-index="{{ item.index }}" onclick="toggleEdit(this)">Edit</button>
|
||||
</div>
|
||||
</div>
|
||||
{% if item.errors %}
|
||||
<div class="alert alert-danger" role="alert">
|
||||
<strong>Validation errors:</strong>
|
||||
<ul class="mb-0">
|
||||
{% for err in item.errors %}
|
||||
<li>{{ err }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
None
|
||||
</div>
|
||||
{% endif %}
|
||||
</p>
|
||||
{% if item.dropped_fields %}
|
||||
<div class="alert alert-info" role="alert">
|
||||
<strong>Note:</strong> The following unsupported fields were dropped: <em>{{ item.dropped_fields|join:", " }}</em>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if item.stripped_fields %}
|
||||
<div class="alert alert-info" role="alert">
|
||||
<strong>Note:</strong> Numeric references were removed from fields: <em>{{ item.stripped_fields|join:", " }}</em>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="mb-2">
|
||||
<label class="form-label"><strong>Title</strong></label>
|
||||
<input class="form-control form-control-sm" name="payload_{{ item.index }}_title" value="{{ item.payload.title|default_if_none:'' }}" disabled>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label"><strong>Stem</strong></label>
|
||||
<textarea class="form-control form-control-sm" name="payload_{{ item.index }}_stem" rows="3" disabled>{{ item.payload.stem|default_if_none:'' }}</textarea>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label"><strong>Answers</strong></label>
|
||||
<div class="mb-1">
|
||||
<label class="form-label small">A</label>
|
||||
<input class="form-control form-control-sm" name="payload_{{ item.index }}_a_answer" value="{{ item.payload.a_answer|default_if_none:'' }}" disabled>
|
||||
<label class="form-label small mt-1">A Feedback</label>
|
||||
<textarea class="form-control form-control-sm" name="payload_{{ item.index }}_a_feedback" rows="2" disabled>{{ item.payload.a_feedback|default_if_none:'' }}</textarea>
|
||||
</div>
|
||||
<div class="mb-1">
|
||||
<label class="form-label small">B</label>
|
||||
<input class="form-control form-control-sm" name="payload_{{ item.index }}_b_answer" value="{{ item.payload.b_answer|default_if_none:'' }}" disabled>
|
||||
<label class="form-label small mt-1">B Feedback</label>
|
||||
<textarea class="form-control form-control-sm" name="payload_{{ item.index }}_b_feedback" rows="2" disabled>{{ item.payload.b_feedback|default_if_none:'' }}</textarea>
|
||||
</div>
|
||||
<div class="mb-1">
|
||||
<label class="form-label small">C</label>
|
||||
<input class="form-control form-control-sm" name="payload_{{ item.index }}_c_answer" value="{{ item.payload.c_answer|default_if_none:'' }}" disabled>
|
||||
<label class="form-label small mt-1">C Feedback</label>
|
||||
<textarea class="form-control form-control-sm" name="payload_{{ item.index }}_c_feedback" rows="2" disabled>{{ item.payload.c_feedback|default_if_none:'' }}</textarea>
|
||||
</div>
|
||||
<div class="mb-1">
|
||||
<label class="form-label small">D</label>
|
||||
<input class="form-control form-control-sm" name="payload_{{ item.index }}_d_answer" value="{{ item.payload.d_answer|default_if_none:'' }}" disabled>
|
||||
<label class="form-label small mt-1">D Feedback</label>
|
||||
<textarea class="form-control form-control-sm" name="payload_{{ item.index }}_d_feedback" rows="2" disabled>{{ item.payload.d_feedback|default_if_none:'' }}</textarea>
|
||||
</div>
|
||||
<div class="mb-1">
|
||||
<label class="form-label small">E</label>
|
||||
<input class="form-control form-control-sm" name="payload_{{ item.index }}_e_answer" value="{{ item.payload.e_answer|default_if_none:'' }}" disabled>
|
||||
<label class="form-label small mt-1">E Feedback</label>
|
||||
<textarea class="form-control form-control-sm" name="payload_{{ item.index }}_e_feedback" rows="2" disabled>{{ item.payload.e_feedback|default_if_none:'' }}</textarea>
|
||||
</div>
|
||||
<div class="mb-1">
|
||||
<label class="form-label small">Best Answer</label>
|
||||
<input class="form-control form-control-sm" name="payload_{{ item.index }}_best_answer" value="{{ item.payload.best_answer|default_if_none:'' }}" disabled>
|
||||
</div>
|
||||
<div class="mb-1">
|
||||
<label class="form-label small">Feedback</label>
|
||||
<textarea class="form-control form-control-sm" name="payload_{{ item.index }}_feedback" rows="2" disabled>{{ item.payload.feedback|default_if_none:'' }}</textarea>
|
||||
</div>
|
||||
<div class="mb-1 small text-muted">Sources: {% if item.payload.sources %}{{ item.payload.sources|join:", " }}{% else %}None{% endif %}</div>
|
||||
<p><strong>M2M fields (click to exclude)</strong></p>
|
||||
<p class="small text-muted">Green = will be created/attached. Click a value to exclude it (grey).</p>
|
||||
<div class="mb-2">
|
||||
@@ -160,6 +192,7 @@ missing_map: {{ item.missing_map|default:"{}" }}</pre>
|
||||
<div id="exclude-container-{{ item.index }}"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
@@ -235,3 +268,22 @@ missing_map: {{ item.missing_map|default:"{}" }}</pre>
|
||||
</script>
|
||||
|
||||
<!-- Bottom controls removed: submit and cancel are inside the form above -->
|
||||
|
||||
<script>
|
||||
// Toggle editing of payload inputs for a specific item (disabled by default)
|
||||
function toggleEdit(btn){
|
||||
const idx = btn.getAttribute('data-item-index');
|
||||
if(!idx) return;
|
||||
const card = document.getElementById('import-item-' + idx);
|
||||
if(!card) return;
|
||||
const selector = 'input[name^="payload_' + idx + '_"], textarea[name^="payload_' + idx + '_"]';
|
||||
const inputs = card.querySelectorAll(selector);
|
||||
if(inputs.length === 0) return;
|
||||
// check current state from first input
|
||||
const currentlyDisabled = inputs[0].disabled === true;
|
||||
// toggle
|
||||
inputs.forEach(function(i){ i.disabled = currentlyDisabled ? false : true; });
|
||||
// update button text
|
||||
btn.textContent = currentlyDisabled ? 'Lock' : 'Edit';
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,129 +1,217 @@
|
||||
{% extends 'sbas/base.html' %}
|
||||
|
||||
{% load partials %}
|
||||
|
||||
{% partialdef links-partial %}
|
||||
|
||||
<div class="btn-group mt-3 mt-md-0" role="group" aria-label="Actions">
|
||||
<a class="btn btn-sm btn-outline-primary" href="{% url 'sbas:question_update' question.id %}">Edit</a>
|
||||
<a class="btn btn-sm btn-outline-secondary" href="{% url 'sbas:question_clone' question.id %}">Clone</a>
|
||||
<a class="btn btn-sm btn-outline-danger" href="{% url 'sbas:question_delete' pk=question.pk %}">Delete</a>
|
||||
<a class="btn btn-sm btn-outline-light" href="{% url 'admin:sbas_question_change' question.id %}">Admin</a>
|
||||
<button class="btn btn-sm btn-outline-info" onclick="return window.create_popup_window('{% url 'feedback_create' question_type='sbas' pk=question.pk %}')">Add Note</button>
|
||||
<button class="btn btn-sm btn-outline-success" data-bs-toggle="modal" data-bs-target="#reviewModal" hx-get="{% url 'sbas:question_set_review' question.pk %}?edit=1&new=1" hx-target="#reviewModalBody" hx-swap="innerHTML" hx-trigger="click">Add Review</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#reviewsModal" hx-get="{% url 'sbas:question_reviews' question.pk %}" hx-target="#reviewsModalBody" hx-swap="innerHTML" hx-trigger="click">Reviews History</button>
|
||||
</div>
|
||||
|
||||
<!-- Reviews modal -->
|
||||
<div class="modal fade" id="reviewsModal" tabindex="-1" aria-labelledby="reviewsModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="reviewsModalLabel">Review history</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body" id="reviewsModalBody">
|
||||
<!-- HTMX will load the reviews list here -->
|
||||
<div class="text-center small text-muted">Loading…</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Review form modal -->
|
||||
<div class="modal fade" id="reviewModal" tabindex="-1" aria-labelledby="reviewModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="reviewModalLabel">Add / Edit Review</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body" id="reviewModalBody">
|
||||
<!-- HTMX will load the review form here -->
|
||||
<div class="text-center small text-muted">Loading</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endpartialdef %}
|
||||
|
||||
{% block content %}
|
||||
<div class="question">
|
||||
<a href="{% url 'sbas:question_update' question.id %}" title="Edit the Question">Edit</a>
|
||||
<a href="{% url 'sbas:question_clone' question.id %}" title="Clone the Question">Clone</a>
|
||||
<a href="{% url 'sbas:question_delete' pk=question.pk %}" title="Delete the Question">Delete</a>
|
||||
<a href="{% url 'admin:sbas_question_change' question.id %}"
|
||||
title="Edit the Question using the admin interface">Admin Edit</a>
|
||||
<a href="#" onclick="return window.create_popup_window('{% url 'feedback_create' question_type='sbas' pk=question.pk %}')"> Add Note</a>
|
||||
<div class="date">
|
||||
Created: {{ question.created_date|date:"d/m/Y"}}
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start mb-3 flex-column flex-md-row">
|
||||
<div>
|
||||
<h2 class="h4 mb-1">{{ question.title|default:"(No title set)" }}</h2>
|
||||
<div class="text-muted small">Created: {{ question.created_date|date:"d/m/Y" }}</div>
|
||||
</div>
|
||||
{% partial links-partial %}
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12 col-lg-8">
|
||||
<div class="mb-3">
|
||||
<div class="mb-2">Review status:</div>
|
||||
<div id="question-review-block" hx-get="{% url 'sbas:question_set_review' question.pk %}" hx-trigger="load" hx-swap="innerHTML">
|
||||
{# Placeholder while HTMX fetches review/form #}
|
||||
</div>
|
||||
{% autoescape off %}
|
||||
<span>{{question.stem}}</span>
|
||||
<div>
|
||||
<ol class="abcde">
|
||||
<li><strong>{{ question.a_answer }}</strong>
|
||||
{% if question.a_feedback %}<p>Feedback:<i>{{ question.a_feedback|default:''|safe }}</i> </p>{% endif %}
|
||||
<div class="card p-3 bg-body-secondary">{{ question.stem }}</div>
|
||||
<ol class="abcde list-group list-group-numbered mt-3">
|
||||
<li class="list-group-item"><strong>{{ question.a_answer }}</strong>
|
||||
{% if question.a_feedback %}<div class="small text-muted mt-1">Feedback: <em>{{ question.a_feedback|safe }}</em></div>{% endif %}
|
||||
</li>
|
||||
<li><strong>{{ question.b_answer }}</strong>
|
||||
{% if question.b_feedback %}<p>Feedback:<i>{{ question.b_feedback|default:''|safe }}</i> </p>{% endif %}
|
||||
<li class="list-group-item"><strong>{{ question.b_answer }}</strong>
|
||||
{% if question.b_feedback %}<div class="small text-muted mt-1">Feedback: <em>{{ question.b_feedback|safe }}</em></div>{% endif %}
|
||||
</li>
|
||||
<li><strong>{{ question.c_answer }}</strong>
|
||||
{% if question.c_feedback %}<p>Feedback:<i>{{ question.c_feedback|default:''|safe }}</i> </p>{% endif %}
|
||||
<li class="list-group-item"><strong>{{ question.c_answer }}</strong>
|
||||
{% if question.c_feedback %}<div class="small text-muted mt-1">Feedback: <em>{{ question.c_feedback|safe }}</em></div>{% endif %}
|
||||
</li>
|
||||
<li><strong>{{ question.d_answer }}</strong>
|
||||
{% if question.d_feedback %}<p>Feedback:<i>{{ question.d_feedback|default:''|safe }}</i> </p>{% endif %}
|
||||
<li class="list-group-item"><strong>{{ question.d_answer }}</strong>
|
||||
{% if question.d_feedback %}<div class="small text-muted mt-1">Feedback: <em>{{ question.d_feedback|safe }}</em></div>{% endif %}
|
||||
</li>
|
||||
<li><strong>{{ question.e_answer }}</strong>
|
||||
{% if question.e_feedback %}<p>Feedback:<i>{{ question.e_feedback|default:''|safe }}</i> </p>{% endif %}
|
||||
<li class="list-group-item"><strong>{{ question.e_answer }}</strong>
|
||||
{% if question.e_feedback %}<div class="small text-muted mt-1">Feedback: <em>{{ question.e_feedback|safe }}</em></div>{% endif %}
|
||||
</li>
|
||||
</ol>
|
||||
Best answer: {{ question.best_answer }} ({{question.get_correct_answer_stripped}})
|
||||
</div>
|
||||
<div class="mt-2">Correct: <span class="badge bg-success text-white fs-5 py-2 px-3 rounded-pill">{{ question.get_correct_answer_stripped }}</span></div>
|
||||
{% endautoescape %}
|
||||
<div>
|
||||
Feedback: {{ question.feedback|linebreaks }}
|
||||
</div>
|
||||
<div>
|
||||
Sources:
|
||||
|
||||
<div class="mb-3">
|
||||
<h5 class="h6">Feedback</h5>
|
||||
<div>{{ question.feedback|linebreaks }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<h5 class="h6">Sources</h5>
|
||||
{% if question.sources %}
|
||||
<ul>
|
||||
<ul class="list-unstyled">
|
||||
{% for source in question.sources %}
|
||||
<li>{{ source }}</li>
|
||||
<li class="small">{{ source }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
None
|
||||
{% endif %}
|
||||
<div>
|
||||
Examinations: {% for exam in question.exams.all %}
|
||||
<a href="{% url 'sbas:exam_overview' pk=exam.pk %}">{{ exam }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div>
|
||||
Findings: {% if question.finding.exists %}
|
||||
<ul>
|
||||
{% for f in question.finding.all %}
|
||||
<li>{{ f.get_link|safe }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
None
|
||||
<div class="small text-muted">None</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
Structures: {% if question.structure.exists %}
|
||||
<ul>
|
||||
{% for s in question.structure.all %}
|
||||
<li>{{ s.get_link|safe }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
None
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
Conditions: {% if question.condition.exists %}
|
||||
<ul>
|
||||
{% for c in question.condition.all %}
|
||||
<li>{{ c.get_link|safe }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
None
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
Presentations: {% if question.presentation.exists %}
|
||||
<ul>
|
||||
{% for p in question.presentation.all %}
|
||||
<li>{{ p.get_link|safe }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
None
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
Subspecialties: {% if question.subspecialty.exists %}
|
||||
<ul>
|
||||
{% for ss in question.subspecialty.all %}
|
||||
<li>{{ ss.get_link|safe }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
None
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
Category: {{ question.category }}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
Author(s):
|
||||
{% if question.author.exists %}
|
||||
{% for u in question.author.all %}
|
||||
{{ u.get_full_name|default:u.username }}{% if not forloop.last %}, {% endif %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
Unknown
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
||||
{% include 'question_notes.html' %}
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-lg-4">
|
||||
<div class="mb-3">
|
||||
<h5 class="h6">Metadata</h5>
|
||||
<div class="mb-2"><strong>Category:</strong> {{ question.category }}</div>
|
||||
<div class="mb-2"><strong>Examinations:</strong>
|
||||
<div>
|
||||
{% for exam in question.exams.all %}
|
||||
<a class="d-inline-block badge bg-secondary text-white text-decoration-none mb-1 me-1" href="{% url 'sbas:exam_overview' pk=exam.pk %}">{{ exam }}</a>
|
||||
{% empty %}
|
||||
<span class="small text-muted">None</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-2"><strong>Authors:</strong>
|
||||
<div>
|
||||
{% if question.author.all %}
|
||||
{% for u in question.author.all %}
|
||||
<span class="d-inline-block small">{{ u.get_full_name|default:u.username }}</span>{% if not forloop.last %}, {% endif %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<span class="small text-muted">Unknown</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-2"><strong>Findings</strong>
|
||||
<div>
|
||||
{% if question.finding.exists %}
|
||||
{% for f in question.finding.all %}
|
||||
<a class="badge bg-info text-white text-decoration-none mb-1 me-1" href="{{ f.get_absolute_url }}">{{ f.name }}</a>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="small text-muted">None</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-2"><strong>Structures</strong>
|
||||
<div>
|
||||
{% if question.structure.exists %}
|
||||
{% for s in question.structure.all %}
|
||||
<a class="badge bg-warning text-dark text-decoration-none mb-1 me-1" href="{{ s.get_absolute_url }}">{{ s.name }}</a>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="small text-muted">None</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-2"><strong>Conditions</strong>
|
||||
<div>
|
||||
{% if question.condition.exists %}
|
||||
{% for c in question.condition.all %}
|
||||
<a class="badge bg-primary text-white text-decoration-none mb-1 me-1" href="{{ c.get_absolute_url }}">{{ c.name }}</a>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="small text-muted">None</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-2"><strong>Presentations</strong>
|
||||
<div>
|
||||
{% if question.presentation.exists %}
|
||||
{% for p in question.presentation.all %}
|
||||
<a class="badge bg-secondary text-white text-decoration-none mb-1 me-1" href="{{ p.get_absolute_url }}">{{ p.name }}</a>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="small text-muted">None</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-2"><strong>Subspecialties</strong>
|
||||
<div>
|
||||
{% if question.subspecialty.exists %}
|
||||
{% for ss in question.subspecialty.all %}
|
||||
<a class="badge bg-dark text-white text-decoration-none mb-1 me-1" href="{{ ss.get_absolute_url }}">{{ ss.name }}</a>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="small text-muted">None</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-2"><strong>Open Access:</strong>
|
||||
<div>
|
||||
{% if question.open_access %}
|
||||
<span class="badge bg-success text-white">Yes</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger text-white">No</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -1,23 +1,30 @@
|
||||
{% extends "sbas/base.html" %}
|
||||
|
||||
{% load static %}
|
||||
{% load partials %}
|
||||
|
||||
{% block js %}
|
||||
<!--<script type="text/javascript" src="/admin/jsi18n/"></script>-->
|
||||
|
||||
<script type="text/javascript">
|
||||
</script>
|
||||
<script type="text/javascript">
|
||||
</script>
|
||||
|
||||
{{ form.media }}
|
||||
{{ form.media }}
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<h2>Submit Question</h2>
|
||||
<form action="" method="post" enctype="multipart/form-data" id="anatomyquestion-form">
|
||||
<div class="d-flex justify-content-between align-items-start mb-3 flex-column flex-md-row">
|
||||
<div>
|
||||
<h2>Submit Question</h2>
|
||||
</div>
|
||||
{% include "sbas/question_detail.html#links-partial" %}
|
||||
</div>
|
||||
|
||||
<form action="" method="post" enctype="multipart/form-data" id="anatomyquestion-form">
|
||||
{% csrf_token %}
|
||||
|
||||
<table>
|
||||
{{ form.as_table }}
|
||||
</table>
|
||||
<input type="submit" class="submit-button" value="Submit" name="submit">
|
||||
</form>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -5,18 +5,37 @@
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div id="view-filter-options">
|
||||
<div id="view-filter-options">
|
||||
<h3>Filter SBA Questions</h3>
|
||||
<form action="" method="get">
|
||||
<div class="row">
|
||||
<div class="col-12 col-lg-3 mb-3">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span class="h6 mb-0">Filters</span>
|
||||
<button class="btn btn-sm btn-outline-secondary d-lg-none" type="button" data-bs-toggle="collapse" data-bs-target="#filter-collapse" aria-expanded="false">Toggle</button>
|
||||
</div>
|
||||
<div id="filter-collapse" class="collapse d-lg-block">
|
||||
<div class="card-body">
|
||||
<form action="" method="get">
|
||||
{{ filter.form|crispy }}
|
||||
<div class="mt-2">
|
||||
<button class="btn btn-primary btn-sm" type="submit">Apply</button>
|
||||
<a class="btn btn-secondary btn-sm" href="?">Reset</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ filter.form }}
|
||||
|
||||
<input class="btn btn-primary btn-sm mt-1 mb-1" type="submit" />
|
||||
</form>
|
||||
</div>
|
||||
<div class="col-12 col-lg-9">
|
||||
<div class="table-responsive">
|
||||
{% autoescape off %}
|
||||
{% load render_table from django_tables2 %}
|
||||
{% render_table table %}
|
||||
{% load render_table from django_tables2 %}
|
||||
{% render_table table %}
|
||||
{% endautoescape %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
+1
-1
@@ -9,7 +9,7 @@ app_name = "sbas"
|
||||
|
||||
urlpatterns = [
|
||||
path("question/", views.QuestionView.as_view(), name="question_view"),
|
||||
path("question/<int:pk>/", views.question_detail, name="question_detail"),
|
||||
#path("question/<int:pk>/", views.question_detail, name="question_detail"),
|
||||
path("question/create/", views.QuestionCreate.as_view(), name="question_create"),
|
||||
path(
|
||||
"question/<int:pk>/update",
|
||||
|
||||
+101
-12
@@ -98,10 +98,10 @@ class AuthorOrCheckerRequiredMixin(object):
|
||||
# return render(request, "sbas/question_view.html", {"questions": questions})
|
||||
|
||||
|
||||
@login_required
|
||||
def question_detail(request, pk):
|
||||
question = get_object_or_404(Question, pk=pk)
|
||||
return render(request, "sbas/question_detail.html", {"question": question})
|
||||
#@login_required
|
||||
#def question_detail(request, pk):
|
||||
# question = get_object_or_404(Question, pk=pk)
|
||||
# return render(request, "sbas/question_detail.html", {"question": question})
|
||||
|
||||
|
||||
def active_exams(request):
|
||||
@@ -487,6 +487,27 @@ def _resolve_m2m(model_class, value):
|
||||
return list(qs[:5])
|
||||
return []
|
||||
|
||||
def _strip_numeric_refs_in_payload(p):
|
||||
"""Return a copy of payload with inline numeric refs removed from any '*_feedback' or 'feedback' fields.
|
||||
Also return a list of keys that were modified.
|
||||
"""
|
||||
if not isinstance(p, dict):
|
||||
return p, []
|
||||
cleaned = dict(p)
|
||||
changed = []
|
||||
# pattern matches [1], [1,2,3], or ranges like [6-9], or mixtures like [1,3-5,7]
|
||||
pattern = re.compile(r"\[\s*(?:\d+(?:\s*-\s*\d+)?)(?:\s*,\s*(?:\d+(?:\s*-\s*\d+)?))*\s*\]")
|
||||
for k, v in list(p.items()):
|
||||
if not isinstance(v, str):
|
||||
continue
|
||||
if k.endswith("_feedback") or k == "feedback":
|
||||
newv = pattern.sub("", v).strip()
|
||||
# collapse multiple spaces
|
||||
newv = re.sub(r"\s{2,}", " ", newv)
|
||||
if newv != v:
|
||||
cleaned[k] = newv
|
||||
changed.append(k)
|
||||
return cleaned, changed
|
||||
|
||||
@login_required
|
||||
@user_passes_test(lambda u: u.is_superuser)
|
||||
@@ -570,6 +591,7 @@ def import_llm_questions(request):
|
||||
new_s, n = pattern.subn(r"\\\\", s)
|
||||
return new_s, n
|
||||
|
||||
|
||||
raw_text_stripped = raw_text.strip()
|
||||
raw_text, sanitized_count = _sanitize_backslashes(raw_text_stripped)
|
||||
logger.debug("import_llm_questions: sanitized input length %d (escaped %d backslashes)", len(raw_text), sanitized_count)
|
||||
@@ -637,13 +659,60 @@ def import_llm_questions(request):
|
||||
|
||||
for idx, payload in enumerate(candidates):
|
||||
item_report = {"index": idx, "status": None, "errors": [], "missing_m2m": [], "resolved_m2m": {}, "payload": payload}
|
||||
for err in validator.iter_errors(payload):
|
||||
item_report["errors"].append(err.message)
|
||||
if item_report["errors"]:
|
||||
logger.debug(f"Validation errors for item {idx}: {item_report['errors']}")
|
||||
report["errors"].append({"index": idx, "errors": item_report["errors"]})
|
||||
item_report["status"] = "invalid"
|
||||
report["per_item"].append(item_report)
|
||||
|
||||
# Strip numeric bracket references from feedback fields (e.g., [1], [1,2]) and record which fields were changed
|
||||
cleaned_payload, stripped = _strip_numeric_refs_in_payload(payload)
|
||||
if stripped:
|
||||
item_report['stripped_fields'] = stripped
|
||||
payload = cleaned_payload
|
||||
|
||||
# Collect validation errors
|
||||
validation_errors = list(validator.iter_errors(payload))
|
||||
if validation_errors:
|
||||
# Separate additionalProperties errors from others
|
||||
addl_errors = [e for e in validation_errors if getattr(e, 'validator', '') == 'additionalProperties']
|
||||
other_errors = [e for e in validation_errors if getattr(e, 'validator', '') != 'additionalProperties']
|
||||
|
||||
if addl_errors and not other_errors:
|
||||
# If the only problems are extra fields, drop them and re-validate the cleaned payload
|
||||
allowed_keys = set(schema.get('properties', {}).keys())
|
||||
extra_keys = [k for k in payload.keys() if k not in allowed_keys]
|
||||
if extra_keys:
|
||||
logger.debug("Validation: item %s has extra keys %s, dropping them for import", idx, extra_keys)
|
||||
# Record dropped fields in the report for user visibility
|
||||
item_report.setdefault('dropped_fields', []).extend(extra_keys)
|
||||
# Create a cleaned payload without extra keys and use it from here on
|
||||
cleaned_payload = {k: v for k, v in payload.items() if k in allowed_keys}
|
||||
# Re-run validation on cleaned payload to be safe
|
||||
cleaned_errors = list(validator.iter_errors(cleaned_payload))
|
||||
if cleaned_errors:
|
||||
# If cleaning still leaves errors, mark invalid
|
||||
item_report['errors'] = [e.message for e in cleaned_errors]
|
||||
logger.debug("Validation errors after cleaning for item %s: %s", idx, item_report['errors'])
|
||||
report['errors'].append({"index": idx, "errors": item_report['errors']})
|
||||
item_report['status'] = 'invalid'
|
||||
report['per_item'].append(item_report)
|
||||
continue
|
||||
else:
|
||||
# Replace payload used downstream with cleaned version
|
||||
payload = cleaned_payload
|
||||
item_report['status'] = 'cleaned'
|
||||
# do not append here; allow the normal processing path to append the full item_report later
|
||||
else:
|
||||
# no extra keys found, treat as invalid to be safe
|
||||
item_report['errors'] = [e.message for e in validation_errors]
|
||||
logger.debug("Validation errors for item %s: %s", idx, item_report['errors'])
|
||||
report['errors'].append({"index": idx, "errors": item_report['errors']})
|
||||
item_report['status'] = 'invalid'
|
||||
report['per_item'].append(item_report)
|
||||
continue
|
||||
else:
|
||||
# Other validation problems exist; report and skip
|
||||
item_report['errors'] = [e.message for e in validation_errors]
|
||||
logger.debug("Validation errors for item %s: %s", idx, item_report['errors'])
|
||||
report['errors'].append({"index": idx, "errors": item_report['errors']})
|
||||
item_report['status'] = 'invalid'
|
||||
report['per_item'].append(item_report)
|
||||
continue
|
||||
|
||||
try:
|
||||
@@ -706,6 +775,9 @@ def import_llm_questions(request):
|
||||
list((item_report.get("resolved_m2m") or {}).keys()),
|
||||
)
|
||||
|
||||
# Ensure the item report payload reflects any cleaning (dropped fields / stripped refs)
|
||||
item_report['payload'] = payload
|
||||
|
||||
# If dry-run, do not save; just report what would happen
|
||||
if dry_run:
|
||||
item_report["status"] = "would_create"
|
||||
@@ -878,7 +950,24 @@ def import_llm_confirm(request):
|
||||
except Exception:
|
||||
errors.append({"index": idx, "errors": ["Missing candidate"]})
|
||||
continue
|
||||
|
||||
# Overlay any edited fields posted from the preview form (names like payload_<idx>_stem)
|
||||
overlay = {}
|
||||
prefix = f"payload_{idx}_"
|
||||
for k, v in request.POST.items():
|
||||
if k.startswith(prefix):
|
||||
field = k[len(prefix):]
|
||||
overlay[field] = v
|
||||
if overlay:
|
||||
# apply overlay onto a shallow copy
|
||||
new_payload = dict(payload)
|
||||
for fk, fv in overlay.items():
|
||||
# convert list-like fields and numeric conversions are not performed here; keep simple strings/lists
|
||||
new_payload[fk] = fv
|
||||
payload = new_payload
|
||||
# Strip numeric bracket references here as well, before confirm import
|
||||
payload, stripped = _strip_numeric_refs_in_payload(payload)
|
||||
if stripped:
|
||||
logger.debug("import_llm_confirm: stripped numeric refs for idx=%s fields=%s", idx, stripped)
|
||||
try:
|
||||
with transaction.atomic():
|
||||
q = Question()
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form hx-post="{% url 'generic:generic_exam_htmx' %}"
|
||||
hx-target="#action-result" hx-swap="innerHTML"
|
||||
hx-include="[name='selection']:checked"
|
||||
>
|
||||
<input type="hidden" name="type" value="{{ type }}" />
|
||||
<div class="mb-2">
|
||||
<label for="id_exam_id">Choose exam</label>
|
||||
<select name="exam_id" id="id_exam_id" class="form-select form-select-sm">
|
||||
{% for ex in exams %}
|
||||
<option value="{{ ex.pk }}">{{ ex.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label>Selected questions</label>
|
||||
<div class="selected-placeholder small text-muted">(will be taken from selected checkboxes)</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-primary btn-sm" type="submit" name="add_from_selection">Add selected</button>
|
||||
<button class="btn btn-secondary btn-sm" type="button" onclick="document.querySelector('#action-result').innerHTML='';">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,10 @@
|
||||
<div class="alert alert-success alert-sm" role="alert">
|
||||
{% if added %}
|
||||
Added {{ added }} question{{ added|pluralize }} to "{{ exam.name }}".
|
||||
{% else %}
|
||||
No new questions were added to "{{ exam.name }}".
|
||||
{% endif %}
|
||||
<div class="mt-2">
|
||||
<button class="btn btn-sm btn-secondary" type="button" onclick="document.querySelector('#action-result').innerHTML='';">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,10 @@
|
||||
<div class="alert alert-info">
|
||||
{% if changed %}
|
||||
Updated {{ changed }} question{{ changed|pluralize }} in "{{ exam.name }}". Open access set to {{ state|yesno:"true,false" }}.
|
||||
{% else %}
|
||||
No questions were updated.
|
||||
{% endif %}
|
||||
<div class="mt-2">
|
||||
<button class="btn btn-sm btn-secondary" type="button" onclick="document.querySelector('#action-result').innerHTML='';">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,39 +1,206 @@
|
||||
|
||||
{% extends app_name|add:'/base.html' %}
|
||||
|
||||
{% load crispy_forms_tags %}
|
||||
{% load render_table from django_tables2 %}
|
||||
{% block css %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div id="view-filter-options">
|
||||
<details class="filter">
|
||||
<summary>
|
||||
<h3>Filter questions</h3>
|
||||
</summary>
|
||||
<form action="" method="get">
|
||||
{{ filter.form }}
|
||||
<input class="btn btn-primary btn-sm mt-1 mb-1" type="submit" />
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
{% render_table table %}
|
||||
|
||||
<button id="delete-selected-btn" class="btn btn-danger btn-sm mt-2"
|
||||
<div class="row mb-3">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between mb-2 align-items-center flex-column flex-md-row">
|
||||
<div class="mb-2 mb-md-0">
|
||||
<div class="btn-toolbar" role="toolbar">
|
||||
<div class="btn-group me-2" role="group">
|
||||
<button id="delete-selected-btn" class="btn btn-danger btn-sm"
|
||||
hx-post="{% url 'generic:bulk_delete_questions' %}"
|
||||
hx-include="[name='selection']:checked" hx-vals='{"app":"{{ app_name|escapejs }}"}'
|
||||
hx-include="[name='selection']:checked"
|
||||
hx-vals='{"app":"{{ app_name|escapejs }}"}'
|
||||
hx-target="#action-result"
|
||||
hx-swap="innerHTML"
|
||||
hx-confirm="Are you sure you want to delete the selected questions? This action cannot be undone.">
|
||||
Delete selected questions</button>
|
||||
hx-confirm="Are you sure you want to delete the selected questions? This action cannot be undone.">Delete selected</button>
|
||||
</div>
|
||||
<div class="btn-group" role="group">
|
||||
<button id="button-select-add-exam" class="btn btn-outline-primary btn-sm"
|
||||
hx-get="{% url 'generic:generic_exam_htmx' %}?type={{ app_name }}"
|
||||
hx-target="#action-result"
|
||||
hx-swap="innerHTML">Add to exam</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="action-result" class="mt-2 mt-md-0"></div>
|
||||
</div>
|
||||
|
||||
<button id="button-select-add-exam"
|
||||
data-exam_json_edit_url="{% url 'generic:generic_exam_json_edit' %}"
|
||||
data-exam_list_url="{% url 'api-1:'|add:app_name|add:'_user_exams' %}"
|
||||
data-type={{app_name}} data-csrf="{{ csrf_token}}">Add selected questions to
|
||||
exam</button>
|
||||
<div class="table-responsive">
|
||||
{% render_table table %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="action-result" class="mt-2"></div>
|
||||
|
||||
|
||||
<!-- Minibar: small visible strip when panel is collapsed -->
|
||||
<div id="bottom-filter-minibar" class="bottom-filter-minibar">
|
||||
<div class="position-relative">
|
||||
<button class="btn btn-sm btn-outline-primary" type="button" data-bs-toggle="collapse" data-bs-target="#bottom-filter-body" aria-controls="#bottom-filter-body">
|
||||
<i class="bi bi-funnel-fill"></i> Filters
|
||||
</button>
|
||||
{# show active filters count if available via GET params #}
|
||||
{% comment %} Compute active filters count (excluding 'page') using supported template tags {% endcomment %}
|
||||
{% with request.GET|length as param_count %}
|
||||
{% if param_count > 0 %}
|
||||
{% if request.GET.page %}
|
||||
{% with param_count|add:"-1" as active_count %}
|
||||
{# active_count may be a string; only show badge when not zero #}
|
||||
{% if active_count != "0" %}
|
||||
<span class="badge bg-primary filter-count-badge">{{ active_count }}</span>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% else %}
|
||||
<span class="badge bg-primary filter-count-badge">{{ param_count }}</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary filter-toggle d-md-none" type="button" data-bs-toggle="collapse" data-bs-target="#bottom-filter-body" aria-expanded="false" aria-controls="#bottom-filter-body">Filters</button>
|
||||
|
||||
<div class="floating-filter shadow-lg">
|
||||
<div id="bottom-filter-body" class="collapse">
|
||||
<div class="container-fluid">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div class="fw-bold">Filters</div>
|
||||
<div>
|
||||
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#bottom-filter-body" aria-expanded="true">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- filter body collapsible so minibar stays visible -->
|
||||
<div class="filter-body mt-2">
|
||||
<form action="" method="get">
|
||||
<div class="row g-3">
|
||||
{% for field in filter.form.visible_fields %}
|
||||
<div class="col-12 col-sm-6 col-md-4 col-lg-3">
|
||||
<div class="filter-card">
|
||||
<div class="form-group mb-1">
|
||||
{{ field.label_tag }}
|
||||
</div>
|
||||
<div class="form-group mb-1">
|
||||
{{ field }}
|
||||
</div>
|
||||
{% if field.errors %}
|
||||
<div class="invalid-feedback d-block">{{ field.errors|striptags }}</div>
|
||||
{% endif %}
|
||||
{% if field.help_text %}
|
||||
<div class="filter-help">{{ field.help_text }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="mt-3 d-flex gap-2 filter-actions">
|
||||
<button class="btn btn-primary btn-sm" type="submit"><i class="bi bi-search"></i> Apply</button>
|
||||
<a class="btn btn-outline-secondary btn-sm" href="?"><i class="bi bi-arrow-counterclockwise"></i> Reset</a>
|
||||
<button class="btn btn-light btn-sm" type="button" data-bs-toggle="collapse" data-bs-target="#bottom-filter-body">Close</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Floating filter bar (fixed at bottom) -->
|
||||
<style>
|
||||
.floating-filter {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 1030;
|
||||
background: var(--bs-body-bg);
|
||||
border-top: 1px solid rgba(0,0,0,0.08);
|
||||
padding: 0.5rem 0.75rem 1rem 0.75rem;
|
||||
}
|
||||
.floating-filter .filter-body {
|
||||
max-height: 50vh;
|
||||
overflow-y: auto;
|
||||
padding-top: .5rem;
|
||||
}
|
||||
.floating-filter .filter-toggle {
|
||||
position: fixed;
|
||||
right: 1rem;
|
||||
bottom: 1rem;
|
||||
z-index: 1040;
|
||||
}
|
||||
/* minibar/tab styling - small visible handle so panel isn't fully hidden */
|
||||
.bottom-filter-minibar {
|
||||
position: fixed;
|
||||
right: 1rem;
|
||||
bottom: 1rem;
|
||||
z-index: 1045;
|
||||
background: transparent;
|
||||
}
|
||||
.bottom-filter-minibar .btn { box-shadow: 0 2px 10px rgba(0,0,0,0.12); }
|
||||
|
||||
/* pretty filter cards */
|
||||
.filter-card {
|
||||
background: var(--bs-white);
|
||||
border: 1px solid rgba(0,0,0,0.04);
|
||||
border-radius: .5rem;
|
||||
padding: .5rem;
|
||||
height: 100%;
|
||||
}
|
||||
.filter-card .form-group { margin-bottom: 0; }
|
||||
.filter-card label { font-weight: 600; font-size: .9rem; }
|
||||
.filter-help { font-size: .8rem; color: #6c757d; }
|
||||
.filter-actions { padding-top: .5rem; }
|
||||
|
||||
/* active filter count badge on minibar */
|
||||
.filter-count-badge {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -6px;
|
||||
}
|
||||
|
||||
/* Dark mode overrides */
|
||||
.floating-filter {
|
||||
background: #0f1720; /* near-black */
|
||||
color: #e6eef6;
|
||||
border-top-color: rgba(255,255,255,0.06);
|
||||
}
|
||||
.bottom-filter-minibar .btn {
|
||||
background: rgba(255,255,255,0.02);
|
||||
color: #e6eef6;
|
||||
border-color: rgba(255,255,255,0.06);
|
||||
}
|
||||
.filter-card {
|
||||
background: #0b1220;
|
||||
border-color: rgba(255,255,255,0.04);
|
||||
color: #e6eef6;
|
||||
}
|
||||
/* target common form-control/form-select/select/textarea so native selects and bootstrap styles
|
||||
are overridden for dark theme inside filter cards */
|
||||
.filter-card .form-control,
|
||||
.filter-card .form-select,
|
||||
.filter-card input,
|
||||
.filter-card select,
|
||||
.filter-card textarea {
|
||||
background: #07101a !important;
|
||||
color: #f1f5f9 !important;
|
||||
border-color: rgba(255,255,255,0.06) !important;
|
||||
}
|
||||
/* options in native multi-selects */
|
||||
.filter-card select option,
|
||||
.filter-card .form-select option {
|
||||
background: #07101a !important;
|
||||
color: #f1f5f9 !important;
|
||||
}
|
||||
/* placeholder color */
|
||||
.filter-card .form-control::placeholder { color: rgba(255,255,255,0.5) !important; }
|
||||
.filter-help { color: #9fb0c7; }
|
||||
.badge.bg-primary.filter-count-badge { background: #0d6efd; color: #fff; }
|
||||
.btn-outline-secondary { color: #e6eef6; border-color: rgba(255,255,255,0.06); }
|
||||
.btn-light { background: #111827; color: #e6eef6; border-color: rgba(255,255,255,0.04); }
|
||||
.filter-card label { color: #e6eef6; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user