Compare commits

..
52 Commits
Author SHA1 Message Date
Ross e687b55e6a Enhance QuestionReviewForm by adding autofocus to comment field and improve status button rendering with active state 2025-10-25 17:28:50 +01:00
Ross d19e937bd4 Enhance QuestionReviewForm layout with Bootstrap styling for status buttons and improve comment field presentation 2025-10-22 22:05:33 +01:00
Ross fa68034f03 Add 'In Progress' status to QuestionReview model for enhanced tracking 2025-10-22 22:02:38 +01:00
Ross 68f45c8561 Refactor QuestionReviewForm to use RadioSelect for status and update template for improved layout 2025-10-22 22:01:49 +01:00
Ross be68fcd0b9 Add QuestionReviewForm for handling question review submissions and update views for form integration 2025-10-22 22:00:05 +01:00
Ross e819645278 Remove Edit and Cancel buttons from review templates for cleaner UI 2025-10-22 21:56:11 +01:00
Ross 9efec6b79b Update button label for reviews history and relocate modal definition for clarity 2025-10-22 21:53:37 +01:00
Ross 3fb5bc2ae8 Add hx-trigger attribute to History button for improved interaction 2025-10-22 21:48:19 +01:00
Ross 6700c6a7d2 Add question reviews functionality with modal display and listing 2025-10-22 21:39:19 +01:00
Ross 55621b2dcc Refactor question review and form templates to use innerHTML for HTMX swaps, enhancing dynamic content updates 2025-10-22 21:32:57 +01:00
Ross b417266e56 Add QuestionReview model and implement review functionality with HTMX integration 2025-10-22 21:00:01 +01:00
Ross 6d589ef630 Add QuestionReview model and link to QuestionBase for enhanced review functionality 2025-10-22 20:15:20 +01:00
Ross 8addc900f5 Refactor exam overview layout to enhance responsiveness and integrate action buttons for improved user interaction 2025-10-21 22:47:22 +01:00
Ross a055e138c4 Enhance exam overview layout with responsive design improvements and candidate count buttons for better user interaction 2025-10-21 22:32:40 +01:00
Ross 132f3c48c9 Add hx-disabled-elt attribute to exam status buttons for improved user experience 2025-10-20 23:11:31 +01:00
Ross 07f121fb69 Refactor exam status display to use buttons for publish results and exam active states, enhancing user interaction and visual clarity. 2025-10-20 23:03:40 +01:00
Ross c7fa3a096c Enhance question display in exam overview by adding question titles and improved numbering format 2025-10-20 22:58:54 +01:00
Ross 196d472608 Add question numbering to exam overview for better clarity 2025-10-20 22:55:25 +01:00
Ross 1a04064e6b Refactor exam overview layout for improved readability and structure, enhancing question display with better styling and organization. 2025-10-20 22:53:35 +01:00
Ross a49f198d26 Add render_best_answer method to capitalize first character of best_answer 2025-10-20 22:49:37 +01:00
Ross 8663482a6b Enhance QuestionFilter with title and stem search capabilities and update fields in Meta class 2025-10-20 22:42:16 +01:00
Ross 5e4fea38d7 Fix layout issue by closing div tag for import items in preview 2025-10-20 22:36:47 +01:00
Ross 45e50e9e93 Enhance import preview functionality with editable fields and overlay logic for payload updates 2025-10-20 22:35:01 +01:00
Ross 8dac99b6d3 Add alert for stripped numeric references in import preview and refactor payload cleaning function 2025-10-20 22:23:10 +01:00
Ross a4f4cb9e96 Add function to strip numeric references from feedback fields in import_llm_questions 2025-10-20 22:16:51 +01:00
Ross 5e84ba3178 Enhance validation process in import_llm_questions to handle additionalProperties errors and clean payloads for improved data integrity 2025-10-20 22:03:36 +01:00
Ross 4050193cec Add validation error alerts to import preview for question items 2025-10-20 21:57:27 +01:00
Ross c975b3c967 Enhance exam overview and status templates with improved layout and functionality for open access management and publish results status. 2025-10-20 21:55:19 +01:00
Ross 329394dcf0 Refactor exam overview template for improved layout and accessibility, adding alerts for JSON status and user group requirements. 2025-10-20 21:37:59 +01:00
Ross 6cb1303630 Add bulk open access management for exam questions in overview template 2025-10-20 21:35:20 +01:00
Ross 9b9cd8c813 Add HTMX endpoint to set open access for exam questions and corresponding result template 2025-10-20 21:33:03 +01:00
Ross 593eafd427 Update view cell in QuestionTable to display SVG icon for open access questions 2025-10-20 21:28:18 +01:00
Ross 7f68fc23c6 Update question detail template to improve exam and finding link visibility with white text 2025-10-20 21:23:02 +01:00
Ross bad0d400a7 Add debug logging for POST requests in generic_exam_htmx and update exam selection form to include checked selections 2025-10-20 21:20:32 +01:00
Ross ae644f9c03 Restrict exam visibility in generic_exam_htmx based on user permissions 2025-10-20 21:18:46 +01:00
Ross 5b8896e2b2 Update exam query in generic_exam_htmx to remove limit on results 2025-10-20 21:16:47 +01:00
Ross 66711f5c15 Add HTMX support for exam selection and update related templates and views 2025-10-20 21:12:42 +01:00
Ross d342e7e047 Implement exam selection feature with HTMX support in exam edit view and add exam selection fragment template 2025-10-20 21:01:03 +01:00
Ross bdb7aff5d1 Add open_access field to Question model and update question detail template 2025-10-20 20:56:37 +01:00
Ross ac9faeccbd Remove unnecessary dark mode media query for cleaner CSS in question table view 2025-10-20 17:50:06 +01:00
Ross 1e7d765fc2 Refactor filter section in question table view for improved layout, usability, and dark mode support 2025-10-20 17:48:20 +01:00
Ross e3055e3998 Normalize whitespace and strip <p> wrappers in question answers and feedback 2025-10-20 17:20:48 +01:00
Ross f6f208b065 Refactor filter section layout in question table view for improved responsiveness and styling 2025-10-20 17:16:32 +01:00
Ross 815d853599 Refactor filter section in question table view for improved usability and styling 2025-10-20 17:05:31 +01:00
Ross 9cffdb3636 Refactor filter section layout in question and table views for improved styling and structure 2025-10-20 16:54:32 +01:00
Ross a2a43e51f9 Refactor filter section layout in question view for improved styling and structure 2025-10-20 16:33:45 +01:00
Ross 101326fea2 Enhance styling of correct answer display in question detail view 2025-10-20 16:31:16 +01:00
Ross b157fe8b00 Refactor question detail view for improved layout and styling 2025-10-20 16:26:59 +01:00
Ross 33b6d0d935 Add title display to question detail view 2025-10-20 16:18:33 +01:00
Ross 3daad1b83b Add Subspecialty autocomplete and filter to QuestionFilter 2025-10-20 16:17:07 +01:00
Ross 512b222ccc Add filters for condition, structure, finding, and presentation to QuestionFilter 2025-10-20 16:05:41 +01:00
Ross 5294c5f2f6 Add display of related SBA questions in finding, presentation, and structure detail views 2025-10-20 15:59:27 +01:00
35 changed files with 1763 additions and 565 deletions
+6 -2
View File
@@ -16,11 +16,15 @@
{% render_table table %}
<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>
data-csrf="{{ csrf_token }}">
Add selected questions to exam
</button>
<div id="exam-options"></div>
<script type="text/javascript">
@@ -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
View File
@@ -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"
+18
View File
@@ -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
View File
@@ -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)
+29
View File
@@ -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')),
],
),
]
+39
View File
@@ -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/>
<div class="btn-group mt-3 mt-md-0" role="group" aria-label="Actions">
{% 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 %}
\
<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 href="{% url 'admin:'|add:exam.get_app_name|add:'_exam_change' exam.id %}" title="Edit the Exam using the admin interface">Admin Edit</a>
<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>
<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>
<div class="mt-2 mt-md-0">
{% include "generic/exam_link_headers.html" %}
</div>
</div>
{% 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>
<div class="mb-2">Part of 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>
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 %}
{% if exam.exam_open_access %}
{% else %}
{% 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>
{% endif %}
<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. They can still be viewed by supervisors.
</div>
<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 %}
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 />
<div class="mt-2 small text-muted">
This exam has <strong>{{ question_number }}</strong> questions &nbsp;&nbsp;
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>
<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.
</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>
</div>
{% endif %}
</div>
</div>
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>
{% endif %}
{% endif %}
<div class="row">
<div class="col-12 col-md-6">
{% if exam.examcollection %}
Exam Collection: <a href="{{ exam.examcollection.get_absolute_url }}">{{ exam.examcollection }}</a> <br/>
<div class="small">Exam Collection: <a href="{{ exam.examcollection.get_absolute_url }}">{{ exam.examcollection }}</a></div>
{% 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="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>
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>
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>
+20
View File
@@ -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(
+240
View File
@@ -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,
},
)
+3 -1
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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"))
+11 -5
View File
@@ -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>
+45 -17
View File
@@ -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>
+193 -105
View File
@@ -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 %}
+7
View File
@@ -1,6 +1,7 @@
{% extends "sbas/base.html" %}
{% load static %}
{% load partials %}
{% block js %}
<!--<script type="text/javascript" src="/admin/jsi18n/"></script>-->
@@ -11,7 +12,13 @@
{{ form.media }}
{% endblock %}
{% block content %}
<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 %}
+23 -4
View File
@@ -7,16 +7,35 @@
<div id="view-filter-options">
<h3>Filter SBA Questions</h3>
<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 }}
<input class="btn btn-primary btn-sm mt-1 mb-1" type="submit" />
{{ 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>
<div class="col-12 col-lg-9">
<div class="table-responsive">
{% autoescape off %}
{% load render_table from django_tables2 %}
{% render_table table %}
{% endautoescape %}
</div>
</div>
</div>
{% endblock %}
+1 -1
View File
@@ -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
View File
@@ -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>
+193 -26
View File
@@ -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>&nbsp;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>&nbsp;Apply</button>
<a class="btn btn-outline-secondary btn-sm" href="?"><i class="bi bi-arrow-counterclockwise"></i>&nbsp;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 %}