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
+23 -19
View File
@@ -6,27 +6,31 @@
{% block content %} {% block content %}
<div id="view-filter-options"> <div id="view-filter-options">
<h3>Filter Anatomy Questions</h3> <h3>Filter Anatomy Questions</h3>
<form action="" method="get"> <form action="" method="get">
{{ filter.form }} {{ filter.form }}
<input class="btn btn-primary btn-sm mt-1 mb-1" type="submit" /> <input class="btn btn-primary btn-sm mt-1 mb-1" type="submit" />
</form> </form>
</div> </div>
{% render_table table %} {% render_table table %}
<button id="button-select-add-exam" <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=anatomy"
data-exam_list_url="{% url 'api-1:anatomy_user_exams' %}" hx-target="#action-result"
data-type="anatomy" hx-swap="innerHTML"
data-csrf="{{ csrf_token}}">Add selected questions to data-exam_json_edit_url="{% url 'generic:generic_exam_json_edit' %}"
exam</button> data-exam_list_url="{% url 'api-1:anatomy_user_exams' %}"
<div id="exam-options"></div> 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 () { $(document).ready(function () {
}); });
</script> </script>
{% endblock %} {% endblock %}
+10 -1
View File
@@ -7,7 +7,7 @@
<a href="{% url 'atlas:finding_delete' pk=finding.pk %}" title="Delete the Finding">Delete</a> <a href="{% url 'atlas:finding_delete' pk=finding.pk %}" title="Delete the Finding">Delete</a>
{% if request.user.is_superuser %} {% if request.user.is_superuser %}
<a href="{% url 'admin:atlas_finding_change' finding.id %}" <a href="{% url 'admin:atlas_finding_change' finding.id %}"
title="Edit the Finding using the admin interface">Admin Edit</a> title="Edit the Finding using the admin interface">Admin Edit</a>
{% endif %} {% endif %}
</div> </div>
<div> <div>
@@ -27,6 +27,15 @@
</ul> </ul>
{% endif %} {% 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 %} {% endblock %}
{% block js %} {% block js %}
+10 -1
View File
@@ -9,7 +9,7 @@
<a href="{% url 'atlas:presentation_delete' pk=presentation.pk %}" title="Delete the Condition">Delete</a> {% endcomment %} <a href="{% url 'atlas:presentation_delete' pk=presentation.pk %}" title="Delete the Condition">Delete</a> {% endcomment %}
{% if request.user.is_superuser %} {% if request.user.is_superuser %}
<a href="{% url 'admin:atlas_presentation_change' presentation.id %}" <a href="{% url 'admin:atlas_presentation_change' presentation.id %}"
title="Edit the Condition using the admin interface">Admin Edit</a> title="Edit the Condition using the admin interface">Admin Edit</a>
{% endif %} {% endif %}
</div> </div>
<div> <div>
@@ -27,6 +27,15 @@
</ul> </ul>
{% endif %} {% 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 %} {% endblock %}
{% block js %} {% block js %}
+10 -1
View File
@@ -9,7 +9,7 @@
<a href="{% url 'atlas:structure_delete' pk=structure.pk %}" title="Delete the Structure">Delete</a> <a href="{% url 'atlas:structure_delete' pk=structure.pk %}" title="Delete the Structure">Delete</a>
{% if request.user.is_superuser %} {% if request.user.is_superuser %}
<a href="{% url 'admin:atlas_structure_change' structure.id %}" <a href="{% url 'admin:atlas_structure_change' structure.id %}"
title="Edit the Structure using the admin interface">Admin Edit</a> title="Edit the Structure using the admin interface">Admin Edit</a>
{% endif %} {% endif %}
</div> </div>
<div> <div>
@@ -27,6 +27,15 @@
{% endfor %} {% endfor %}
</ul> </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 %} {% endblock %}
{% block js %} {% block js %}
+6 -1
View File
@@ -2,7 +2,7 @@ from django.urls import path, include
from django.views.generic import RedirectView, TemplateView 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 from . import views
app_name = "atlas" app_name = "atlas"
@@ -528,6 +528,11 @@ urlpatterns = [
views.StructureAutocomplete.as_view(model=Structure, create_field="name", validate_create=True), views.StructureAutocomplete.as_view(model=Structure, create_field="name", validate_create=True),
name="structure-autocomplete", 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/", views.PresentationView.as_view(), name="presentation_view"),
path( path(
"presentation/<int:pk>", views.presentation_detail, name="presentation_detail" "presentation/<int:pk>", views.presentation_detail, name="presentation_detail"
+18
View File
@@ -1978,6 +1978,24 @@ class StructureAutocomplete(autocomplete.Select2QuerySetView):
return qs 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 @login_required
# @user_is_atlas_editor # @user_is_atlas_editor
def categories_list(request): def categories_list(request):
+18 -1
View File
@@ -11,6 +11,7 @@ from django.forms import (
DateField, DateField,
SplitDateTimeField, SplitDateTimeField,
SplitDateTimeWidget, SplitDateTimeWidget,
) )
from django.forms import inlineformset_factory from django.forms import inlineformset_factory
from atlas.models import CaseCollection from atlas.models import CaseCollection
@@ -26,10 +27,11 @@ from generic.models import (
UserGrades, UserGrades,
UserProfile, UserProfile,
UserUserGroup, UserUserGroup,
QuestionReview
) )
from django.contrib.admin.widgets import FilteredSelectMultiple 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 rapids.models import Rapid
from anatomy.models import AnatomyQuestion from anatomy.models import AnatomyQuestion
@@ -832,3 +834,18 @@ class UsersAutocompleteForm(Form):
)) ))
autocomplete_register(UserAutocomplete) 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") notes = GenericRelation("generic.QuestionNote")
reviews = GenericRelation("generic.QuestionReview")
class Meta: class Meta:
abstract = True abstract = True
@@ -1315,6 +1317,43 @@ class NoteType(models.Model):
def __str__(self): def __str__(self):
return self.note_type 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): class QuestionNote(models.Model):
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) 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 %} {% if can_edit %}
<a href="{% url exam.get_app_name|add:':exam_overview' exam.id %}" title="View the exam">View</a> <a class="btn btn-sm btn-outline-primary" href="{% url exam.get_app_name|add:':exam_overview' exam.id %}">View</a>
\ <a href="{% url exam.get_app_name|add:':exam_update' exam.id %}" title="Edit the Exam">Edit</a> <a class="btn btn-sm btn-outline-primary" href="{% url exam.get_app_name|add:':exam_update' exam.id %}">Edit</a>
\ <a href="{% url exam.get_app_name|add:':exam_delete' exam.id %}" title="Delete the Exam">Delete</a> <a class="btn btn-sm btn-outline-secondary" href="{% url exam.get_app_name|add:':exam_clone' exam.id %}">Clone</a>
\ <a href="{% url exam.get_app_name|add:':exam_clone' exam.id %}" title="Clone the Exam">Clone</a> <a class="btn btn-sm btn-outline-danger" href="{% url exam.get_app_name|add:':exam_delete' exam.id %}">Delete</a>
\ <a href="{% url exam.get_app_name|add:':exam_authors' exam.id %}" title="Edit Exam Authors">Authors</a> <a class="btn btn-sm btn-outline-secondary" href="{% url exam.get_app_name|add:':exam_authors' exam.id %}">Authors</a>
\ <a href="{% url exam.get_app_name|add:':exam_markers' exam.id %}" title="Edit Exam Markers">Markers</a> <a class="btn btn-sm btn-outline-secondary" href="{% url exam.get_app_name|add:':exam_markers' exam.id %}">Markers</a>
{% endif %} {% endif %}
{% if can_edit and request.user.is_superuser %}
\ {% if request.user.is_superuser %}
{% endif %} <a class="btn btn-sm btn-outline-light" href="{% url 'admin:'|add:exam.get_app_name|add:'_exam_change' exam.id %}">Admin</a>
{% if request.user.is_superuser %} {% endif %}
<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> </div>
{% endif %}
@@ -1,134 +1,199 @@
{% include "generic/exam_link_headers.html" %}
<h1>Exam: {{ exam }}</h1>
{% include 'exam_notes.html' %} <div class="container-fluid mb-3">
<div class="row align-items-start">
<div class="col-12 col-md-8">
{% if exam.examcollection %} {# Responsive: title to left, action buttons (include) to the right on md+ #}
<div>This exam is part of the collection: <a href="{{ exam.examcollection.get_absolute_url }}">{{ exam.examcollection }}</a></div> <div class="d-flex justify-content-between align-items-start flex-column flex-md-row mb-1">
{% endif %} <div>
<h1 class="h4 mb-1">Exam: {{ exam }}</h1>
</div>
{% if exam.recreate_json %} <div class="mt-2 mt-md-0">
<div class="alert alert-warning" role="alert"> {% include "generic/exam_link_headers.html" %}
Exam JSON may be out of date. </div>
<a href="{% url exam.get_app_name|add:':exam_json_recreate' pk=exam.pk %}">Click here to force refresh</a>
</div>
{% endif %}
{% if exam.exam_mode %}
{% if exam.exam_open_access %}
{% else %}
{% 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> </div>
{% if not candidate_count.0 and not candidate_count.1 %} {% include 'exam_notes.html' %}
<div class="alert alert-danger" role="alert">
This exam does not have any candidates. {% if exam.examcollection %}
<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 %}">Force refresh</a>
</div> </div>
{% endif %} {% endif %}
{% endif %}
{% endif %}
{% endif %}
{% if exam.archive %} {% if exam.exam_mode and not exam.exam_open_access %}
<div class="alert alert-info" role="alert"> {% if not exam.cid_user_groups.count and not exam.user_user_groups.count %}
This exam is archived. Results and submitted answers will not be available to candidates. They can still be viewed by supervisors. <div class="alert alert-danger" role="alert">
</div> This exam does not have any user / cid groups added.
{% endif %} </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 %}
{% endif %}
{% endif %}
This exam has {{question_number}} questions. {% if exam.archive %}
<span title="Time per question: {% widthratio exam.time_limit question_number 1 %} seconds">Time limit: {{exam.time_limit}} seconds [{{exam.get_time_limit}}] <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>
<i class="bi bi-info-circle"></i> {% endif %}
</span>.
Exam mode: {{ exam.exam_mode }}<br />
{% if exam.exam_mode %} <div class="mt-2 small text-muted">
{% if exam.exam_open_access %}
This exam is available to all registered users.<br />
{% 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 />
{% endif %}
{% endif %} 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>)
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> <div class="mt-2">
<summary>Click here to show management options</summary> Exam mode: <strong>{{ exam.exam_mode }}</strong>
<button title="This will clear all user answers and attempts" {% if exam.exam_mode %}
hx-post="{% url exam.get_app_name|add:':exam_reset_answers' exam.pk %}" {% if exam.exam_open_access %}
hx-swap="outerHTML" <div class="small text-muted">Available to all registered users.</div>
hx-confirm="Are you sure you want to reset all answers? This action cannot be undone." {% else %}
>Reset all answers</button> {# Improved candidate counts: use compact buttons with badges and icons; stack on xs, inline on md+ #}
</details> <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 %}
</div>
</div> </div>
</details> <div class="col-12 col-md-4">
{% endif %} {# Cards stack vertically on mobile, align on md+ using gap utilities #}
<div class="d-flex flex-column gap-2">
Open access: {{ exam.open_access }}<br /> <div class="card">
<div class="card-body p-2">
{% if exam.start_date %} <div class="d-flex justify-content-between align-items-center">
Start date: {{ exam.start_date }} {% include "generic/partials/exams/exam_status.html#exam-active" %}
{% endif %} {% include "generic/partials/exams/exam_status.html#publish-results" %}
{% if exam.end_date %} </div>
/ End date: {{ exam.end_date }} <div class="mt-2 small text-muted">
{% endif %} {% 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>
{% include "generic/partials/exams/exam_status.html#exam-active" %} <div class="card">
{% if exam.exam_mode %} <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>
{% include "generic/partials/exams/exam_status.html#publish-results" %} <div class="card">
{% if exam.get_app_name == "anatomy" or exam.get_app_name == "longs" %} <div class="card-body p-2 small">
<p><a href="{% url exam.get_app_name|add:':mark_overview' pk=exam.pk %}"><button>Mark exam</button></a></p> Supervisor visible: <strong>{{ exam.results_supervisor_visible }}</strong>
{% endif %} </div>
{% endif %} </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>
{% if exam.examcollection %} <div class="container-fluid">
Exam Collection: <a href="{{ exam.examcollection.get_absolute_url }}">{{ exam.examcollection }}</a> <br/> <div class="row">
{% endif %} <div class="col-12">
Author(s): {% for author in exam.author.all %} {% if can_edit and exam.exam_mode %}
{{ author }}{% if not forloop.last %}, {% endif %} <div class="card mb-2">
{% endfor %}<br/> <div class="card-body p-2">
<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 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>
</div>
</div>
</details>
</div>
</div>
{% endif %}
</div>
</div>
<details> <div class="row">
<summary>Extra</summary> <div class="col-12 col-md-6">
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. {% if exam.examcollection %}
<button title="This will clear all user answers and attempts" <div class="small">Exam Collection: <a href="{{ exam.examcollection.get_absolute_url }}">{{ exam.examcollection }}</a></div>
hx-post="{% url exam.get_app_name|add:':order_questions' exam.pk %}" {% endif %}
hx-swap="outerHTML" <div class="small mt-1">Author(s): {% for author in exam.author.all %}{{ author }}{% if not forloop.last %}, {% endif %}{% endfor %}</div>
>Order questions</button> </div>
</details> <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 %} {% block css %}
<style> <style>
.answer-management[open] { .answer-management[open] { border: 1px dashed lightgrey; }
border: 1px dashed lightgrey;
border-top: 1px dotted lightgrey;
}
</style> </style>
{% endblock css %} {% endblock css %}
@@ -1,30 +1,61 @@
{% load partials %} {% load partials %}
{% partialdef publish-results %} {% partialdef publish-results %}
<div id="publish-results-status" > <div id="publish-results-status" class="d-flex align-items-center gap-2">
Publish results: <span>{{exam.publish_results}}</span> <div class="me-2 small text-muted">Publish results</div>
<button <div>
hx-post="{% url exam.get_app_name|add:':exam_toggle_results_published' exam.pk %}" {% if exam.publish_results %}
hx-swap="outerHTML" <button
hx-target="#publish-results-status" class="btn btn-sm btn-success"
class="btn btn-sm btn-outline-primary reduce-opacity" hx-post="{% url exam.get_app_name|add:':exam_toggle_results_published' exam.pk %}"
> hx-swap="outerHTML"
{% if exam.publish_results %}Unpublish Results{% else %}Publish Results{% endif %} hx-target="#publish-results-status"
</button> title="Click to unpublish results"
</div> 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 %} {% endpartialdef publish-results %}
{% partialdef exam-active %} {% partialdef exam-active %}
<div id="exam-active-status"> <div id="exam-active-status" class="d-flex align-items-center gap-2">
Exam active: <span>{{exam.active}}</span> <div class="me-2 small text-muted">Exam active</div>
<button <div>
hx-post="{% url exam.get_app_name|add:':exam_toggle_active' exam.pk %}" {% if exam.active %}
hx-swap="outerHTML" <button
hx-target="#exam-active-status" class="btn btn-sm btn-success"
class="btn btn-sm btn-outline-primary reduce-opacity" hx-post="{% url exam.get_app_name|add:':exam_toggle_active' exam.pk %}"
> hx-swap="outerHTML"
{% if exam.active %}Deactivate Exam{% else %}Activate Exam{% endif %} hx-target="#exam-active-status"
</button> title="Click to deactivate this exam"
</div> 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 %} {% 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, views.generic_exam_json_edit,
name="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("bulk_delete_questions/", views.bulk_delete_questions, name="bulk_delete_questions"),
path( path(
"examination-autocomplete", "examination-autocomplete",
@@ -266,6 +276,16 @@ def generic_view_urls(generic_views: GenericViewBase):
path( path(
"question/<int:pk>/", generic_views.question_detail, name="question_detail" "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/fail", generic_views.question_thumbnail_fail, name="series_thumbnail_fail"),
path("question/<int:pk>/thumbnail", generic_views.question_thumbnail, name="series_thumbnail"), path("question/<int:pk>/thumbnail", generic_views.question_thumbnail, name="series_thumbnail"),
path( path(
+240
View File
@@ -433,6 +433,22 @@ def get_examination_id(request):
# Generic view to dispatch to indivuals app views # Generic view to dispatch to indivuals app views
@login_required @login_required
def generic_exam_json_edit(request): 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": if request.method == "POST":
question_type = request.POST.get("type") question_type = request.POST.get("type")
@@ -468,6 +484,16 @@ def generic_exam_json_edit(request):
if "add_exam_questions" in request.POST: if "add_exam_questions" in request.POST:
question_ids = json.loads(request.POST.get("add_exam_questions")) 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) # question_objects = Question.objects.filter(pk__in=question_ids)
add_count = 0 add_count = 0
@@ -541,6 +567,139 @@ def generic_exam_json_edit(request):
return JsonResponse(data, status=400) 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): class ExamViews(View, LoginRequiredMixin):
def __init__( def __init__(
self, self,
@@ -1829,6 +1988,7 @@ class ExamViews(View, LoginRequiredMixin):
"view_feedback": view_feedback, "view_feedback": view_feedback,
"can_edit": can_edit, "can_edit": can_edit,
"remote_url": settings.REMOTE_URL, "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) question_json = question.get_question_json(based=False, feedback=True)
return JsonResponse(question_json) 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)) @method_decorator(user_passes_test(lambda u: u.is_superuser))
def user_answer_delete_multiple(self, request): def user_answer_delete_multiple(self, request):
print(request.POST["answer_ids"]) print(request.POST["answer_ids"])
@@ -2840,6 +3079,7 @@ class GenericViewBase:
"view_feedback": view_feedback, "view_feedback": view_feedback,
"remote_url": settings.REMOTE_URL, "remote_url": settings.REMOTE_URL,
"can_edit": question.can_edit(request.user), "can_edit": question.can_edit(request.user),
"app_name": self.app_name,
}, },
) )
+5 -3
View File
@@ -16,9 +16,11 @@
{% render_table table %} {% render_table table %}
<button id="button-select-add-exam" <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"
data-exam_list_url="{% url 'api-1:rapid_user_exams' %}" hx-target="#action-result"
data-type="rapid" data-csrf="{{ csrf_token}}">Add selected questions to 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> exam</button>
<div id="exam-options"></div> <div id="exam-options"></div>
+50 -2
View File
@@ -1,6 +1,9 @@
import django_filters import django_filters
from dal import autocomplete
from .models import Category, Question, UserAnswer, Exam 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 generic.filters import UserAnswerFilter
from django.contrib.auth.models import User from django.contrib.auth.models import User
@@ -30,12 +33,42 @@ def get_authors(request):
class QuestionFilter(django_filters.FilterSet): 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") exams = django_filters.ModelMultipleChoiceFilter(queryset=get_exams, null_label="No exam")
author = django_filters.ModelMultipleChoiceFilter(queryset=get_authors, null_label="No author") 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: class Meta:
model = Question 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): 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(): 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) super(QuestionFilter, self).__init__(data=data, queryset=queryset, prefix=prefix, request=request)
pass 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 UserAnswerFilter(django_filters.FilterSet):
class Meta: class Meta:
model= UserAnswer model= UserAnswer
+1
View File
@@ -148,6 +148,7 @@ class QuestionForm(ModelForm):
"condition", "condition",
"presentation", "presentation",
"subspecialty", "subspecialty",
"open_access",
] ]
widgets = { 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.core.files.storage import FileSystemStorage
from django.conf import settings from django.conf import settings
from django.utils.html import format_html from django.utils.html import format_html
import re
from django.urls import reverse from django.urls import reverse
@@ -25,6 +26,7 @@ class Category(models.Model):
return self.category return self.category
@reversion.register
class Question(QuestionBase): class Question(QuestionBase):
title = models.CharField(max_length=200, help_text="Short title for question", blank=True, null=True) title = models.CharField(max_length=200, help_text="Short title for question", blank=True, null=True)
stem = models.TextField( stem = models.TextField(
@@ -106,22 +108,60 @@ class Question(QuestionBase):
presentation = models.ManyToManyField(Presentation, blank=True, related_name="sbas_questions") presentation = models.ManyToManyField(Presentation, blank=True, related_name="sbas_questions")
subspecialty = models.ManyToManyField(Subspecialty, 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) #notes = GenericRelation(QuestionNote)
def __str__(self): def __str__(self):
return self.stem return self.stem
def clean(self): 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: if self.a_answer:
self.a_answer = self.a_answer.strip() self.a_answer = _strip_outer_p(self.a_answer)
if self.b_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: if self.c_answer:
self.c_answer = self.c_answer.strip() self.c_answer = _strip_outer_p(self.c_answer)
if self.d_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: 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): def get_app_name(self):
return "sbas" return "sbas"
+19 -1
View File
@@ -8,11 +8,19 @@ from django.utils.html import format_html
class QuestionTable(tables.Table): 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( #edit = tables.LinkColumn(
# "sbas:sbas_question_update", text="Edit", args=[A("pk")], orderable=False # "sbas:sbas_question_update", text="Edit", args=[A("pk")], orderable=False
#) #)
view = tables.LinkColumn( 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( #delete = tables.LinkColumn(
# "sbas:question_delete", text="Delete", args=[A("pk")], orderable=False # "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 format_html("<h5>{}</h5>{}", title, mark_safe(value))
return 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): class UserAnswerTable(tables.Table):
select = tables.CheckBoxColumn(accessor=("pk")) select = tables.CheckBoxColumn(accessor=("pk"))
+42 -36
View File
@@ -5,46 +5,52 @@
{% endblock %} {% endblock %}
{% block navigation %} {% block navigation %}
<nav class="navbar navbar-expand-lg navbar-dark submenu"> <nav class="navbar navbar-expand-lg navbar-dark submenu">
<div class="container-fluid"> <div class="container-fluid">
<a class="navbar-brand" href="{% url 'sbas:index' %}">SBAs</a> <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"> <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
</button> </button>
<div class="collapse navbar-collapse"> <div class="collapse navbar-collapse">
<ul class="navbar-nav"> <ul class="navbar-nav">
{% if request.user.is_authenticated %} {% if request.user.is_authenticated %}
<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>
<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>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{% url 'sbas:exam_create' %}" title="Create a new exam"><i class="bi bi-mortarboard"></i> Exam</a></li>
<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><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>
</li>
{% if request.user.is_superuser %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{% url 'sbas:user_answer_table_view' %}" title="User answers">Answers</a> <a class="nav-link" href="{% url 'sbas:exam_list' %}"><i class="bi bi-mortarboard"></i> Exams</a>
</li> </li>
<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>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{% url 'sbas:exam_create' %}" title="Create a new exam"><i class="bi bi-mortarboard"></i> Exam</a></li>
<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" 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>
</li>
{% if request.user.is_superuser %}
<li class="nav-item">
<a class="nav-link" href="{% url 'sbas:user_answer_table_view' %}" title="User answers">Answers</a>
</li>
{% endif %}
{% endif %} {% endif %}
{% endif %} </ul>
</ul> </div>
</div> </div>
</div> </nav>
</nav>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{% endblock %} {% endblock %}
+52 -24
View File
@@ -8,33 +8,61 @@
{% block content %} {% block content %}
{% load thumbnail %} {% 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 %} <div class="row g-3">
<ol id="full-question-list-sba"> {% for question in questions.all %}
{% for question in questions.all %} <div class="col-12">
<div class="card">
<li> <div class="card-body">
<span> <div class="d-flex justify-content-between align-items-start mb-2">
{{ question.stem|safe }} <div class="flex-grow-1">
<ol type="a" class="abcde"> <span><span class="badge bg-secondary text-white me-2">Q{{ forloop.counter }}</span>: {{ question.title }}</span>
<li> {{ question.a_answer|safe }} </li> <div class="question-stem">{{ question.stem|safe }}</div>
<li> {{ question.b_answer|safe }} </li> </div>
<li> {{ question.c_answer|safe }} </li> <div class="ms-3 text-end">
<li> {{ question.d_answer|safe }} </li> <div class="small text-muted">Category: {{ question.category }}</div>
<li> {{ question.e_answer|safe }} </li> Best answer : {{question.best_answer}} ({{question.get_correct_answer_stripped}}) <div class="mt-1">
</ol> <a class="btn btn-sm btn-outline-primary" href="{% url 'sbas:question_detail' pk=question.pk %}">View</a>
</span> <a class="btn btn-sm btn-outline-secondary" href="{% url 'admin:sbas_question_change' question.id %}">Edit</a>
Category: {{ question.category }}, <a href="{% url 'sbas:question_detail' pk=question.pk %}">View</a> <a </div>
href="{% url 'admin:sbas_question_change' question.id %}">Edit</a> </div>
</div>
</li>
{% endfor %}
</ol>
{% endautoescape %}
<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>
</ol>
</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 %}
</div>
</div> </div>
<script type="text/javascript"> <script type="text/javascript">
$(document).ready(function () { $(document).ready(function () {
+185 -133
View File
@@ -2,162 +2,195 @@
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="session_key" value="{{ session_key }}"> <input type="hidden" name="session_key" value="{{ session_key }}">
{% for item in items %} {% for item in items %}
<div class="card mb-2"> <div class="card mb-2" id="import-item-{{ item.index }}">
<div class="card-body"> <div class="card-body">
<h5 class="card-title">Question {{ item.index|add:1 }}</h5> <div class="d-flex justify-content-between align-items-start">
{% if item.payload %} <h5 class="card-title mb-0">Question {{ item.index|add:1 }}</h5>
<p><strong>Title:</strong> {{ item.payload.title|default:'(No title)'|safe }}</p> <div>
<p><strong>Stem:</strong> {{ item.payload.stem|default:''|safe }}</p> <button type="button" class="btn btn-sm btn-outline-secondary ms-2" data-item-index="{{ item.index }}" onclick="toggleEdit(this)">Edit</button>
<p><strong>Answers:</strong> </div>
<ul> </div>
<li>A: {{ item.payload.a_answer|default:''|safe }}</li> {% if item.errors %}
{% if item.payload.a_feedback %} <div class="alert alert-danger" role="alert">
<p><strong>A Feedback:</strong> {{ item.payload.a_feedback|default:''|safe }}</p> <strong>Validation errors:</strong>
{% endif %} <ul class="mb-0">
<li>B: {{ item.payload.b_answer|default:''|safe }}</li> {% for err in item.errors %}
{% if item.payload.b_feedback %} <li>{{ err }}</li>
<p><strong>B Feedback:</strong> {{ item.payload.b_feedback|default:''|safe }}</p> {% endfor %}
{% 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> </ul>
<p><strong>Best Answer:</strong> {{ item.payload.best_answer|default:'' }} </div>
</p> {% endif %}
<p><strong>Feedback:</strong> {{ item.payload.feedback|default:''|safe }}</p> {% if item.dropped_fields %}
<p><strong>Sources:</strong> <div class="alert alert-info" role="alert">
{% if item.payload.sources %} <strong>Note:</strong> The following unsupported fields were dropped: <em>{{ item.dropped_fields|join:", " }}</em>
<ul> </div>
{% for source in item.payload.sources %} {% endif %}
<li>{{ source }}</li> {% if item.stripped_fields %}
{% endfor %} <div class="alert alert-info" role="alert">
</ul> <strong>Note:</strong> Numeric references were removed from fields: <em>{{ item.stripped_fields|join:", " }}</em>
{% else %} </div>
None
{% endif %}
</p>
{% endif %} {% endif %}
<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"> <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">
<!-- Finding --> <!-- Finding -->
<div class="m2m-group" data-model="Finding"><strong>Finding:</strong> <div class="m2m-group" data-model="Finding"><strong>Finding:</strong>
{% if item.resolved_m2m.finding %} {% if item.resolved_m2m.finding %}
{% for val in item.resolved_m2m.finding %} {% for val in item.resolved_m2m.finding %}
<button type="button" class="m2m-toggle btn btn-sm btn-success ms-1" data-idx="{{ item.index }}" data-model="Finding" data-value="{{ val }}">{{ val }}</button> <button type="button" class="m2m-toggle btn btn-sm btn-success ms-1" data-idx="{{ item.index }}" data-model="Finding" data-value="{{ val }}">{{ val }}</button>
{% endfor %}
{% endif %}
{% if item.missing_map.finding %}
{% for val in item.missing_map.finding %}
<button type="button" class="m2m-toggle btn btn-sm btn-success ms-1" data-idx="{{ item.index }}" data-model="Finding" data-value="{{ val }}">{{ val }}</button>
{% endfor %}
{% endif %}
<span class="text-muted m2m-none" style="display:none">(none)</span>
{% if item.missing_map.finding %}
<div class="small text-info">Would create: {{ item.missing_map.finding|join:", " }}</div>
{% endif %}
</div>
</div>
<!-- Structure -->
<div class="m2m-group" data-model="Structure"><strong>Structure:</strong>
{% if item.resolved_m2m.structure %}
{% for val in item.resolved_m2m.structure %}
<button type="button" class="m2m-toggle btn btn-sm btn-success ms-1" data-idx="{{ item.index }}" data-model="Structure" data-value="{{ val }}">{{ val }}</button>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
{% if item.missing_map.finding %} {% if item.missing_map.structure %}
{% for val in item.missing_map.finding %} {% for val in item.missing_map.structure %}
<button type="button" class="m2m-toggle btn btn-sm btn-success ms-1" data-idx="{{ item.index }}" data-model="Finding" data-value="{{ val }}">{{ val }}</button> <button type="button" class="m2m-toggle btn btn-sm btn-success ms-1" data-idx="{{ item.index }}" data-model="Structure" data-value="{{ val }}">{{ val }}</button>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
<span class="text-muted m2m-none" style="display:none">(none)</span> <span class="text-muted m2m-none" style="display:none">(none)</span>
{% if item.missing_map.finding %} {% if item.missing_map.structure %}
<div class="small text-info">Would create: {{ item.missing_map.finding|join:", " }}</div> <div class="small text-info">Would create: {{ item.missing_map.structure|join:", " }}</div>
{% endif %} {% endif %}
</div> </div>
</div>
<!-- Structure -->
<div class="m2m-group" data-model="Structure"><strong>Structure:</strong>
{% if item.resolved_m2m.structure %}
{% for val in item.resolved_m2m.structure %}
<button type="button" class="m2m-toggle btn btn-sm btn-success ms-1" data-idx="{{ item.index }}" data-model="Structure" data-value="{{ val }}">{{ val }}</button>
{% endfor %}
{% endif %}
{% if item.missing_map.structure %}
{% for val in item.missing_map.structure %}
<button type="button" class="m2m-toggle btn btn-sm btn-success ms-1" data-idx="{{ item.index }}" data-model="Structure" data-value="{{ val }}">{{ val }}</button>
{% endfor %}
{% endif %}
<span class="text-muted m2m-none" style="display:none">(none)</span>
{% if item.missing_map.structure %}
<div class="small text-info">Would create: {{ item.missing_map.structure|join:", " }}</div>
{% endif %}
</div>
<!-- Condition --> <!-- Condition -->
<div class="m2m-group" data-model="Condition"><strong>Condition:</strong> <div class="m2m-group" data-model="Condition"><strong>Condition:</strong>
{% if item.resolved_m2m.condition %} {% if item.resolved_m2m.condition %}
{% for val in item.resolved_m2m.condition %} {% for val in item.resolved_m2m.condition %}
<button type="button" class="m2m-toggle btn btn-sm btn-success ms-1" data-idx="{{ item.index }}" data-model="Condition" data-value="{{ val }}">{{ val }}</button> <button type="button" class="m2m-toggle btn btn-sm btn-success ms-1" data-idx="{{ item.index }}" data-model="Condition" data-value="{{ val }}">{{ val }}</button>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
{% if item.missing_map.condition %} {% if item.missing_map.condition %}
{% for val in item.missing_map.condition %} {% for val in item.missing_map.condition %}
<button type="button" class="m2m-toggle btn btn-sm btn-success ms-1" data-idx="{{ item.index }}" data-model="Condition" data-value="{{ val }}">{{ val }}</button> <button type="button" class="m2m-toggle btn btn-sm btn-success ms-1" data-idx="{{ item.index }}" data-model="Condition" data-value="{{ val }}">{{ val }}</button>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
<span class="text-muted m2m-none" style="display:none">(none)</span> <span class="text-muted m2m-none" style="display:none">(none)</span>
{% if item.missing_map.condition %} {% if item.missing_map.condition %}
<div class="small text-info">Would create: {{ item.missing_map.condition|join:", " }}</div> <div class="small text-info">Would create: {{ item.missing_map.condition|join:", " }}</div>
{% endif %} {% endif %}
</div> </div>
<!-- Presentation --> <!-- Presentation -->
<div class="m2m-group" data-model="Presentation"><strong>Presentation:</strong> <div class="m2m-group" data-model="Presentation"><strong>Presentation:</strong>
{% if item.resolved_m2m.presentation %} {% if item.resolved_m2m.presentation %}
{% for val in item.resolved_m2m.presentation %} {% for val in item.resolved_m2m.presentation %}
<button type="button" class="m2m-toggle btn btn-sm btn-success ms-1" data-idx="{{ item.index }}" data-model="Presentation" data-value="{{ val }}">{{ val }}</button> <button type="button" class="m2m-toggle btn btn-sm btn-success ms-1" data-idx="{{ item.index }}" data-model="Presentation" data-value="{{ val }}">{{ val }}</button>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
{% if item.missing_map.presentation %} {% if item.missing_map.presentation %}
{% for val in item.missing_map.presentation %} {% for val in item.missing_map.presentation %}
<button type="button" class="m2m-toggle btn btn-sm btn-success ms-1" data-idx="{{ item.index }}" data-model="Presentation" data-value="{{ val }}">{{ val }}</button> <button type="button" class="m2m-toggle btn btn-sm btn-success ms-1" data-idx="{{ item.index }}" data-model="Presentation" data-value="{{ val }}">{{ val }}</button>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
<span class="text-muted m2m-none" style="display:none">(none)</span> <span class="text-muted m2m-none" style="display:none">(none)</span>
{% if item.missing_map.presentation %} {% if item.missing_map.presentation %}
<div class="small text-info">Would create: {{ item.missing_map.presentation|join:", " }}</div> <div class="small text-info">Would create: {{ item.missing_map.presentation|join:", " }}</div>
{% endif %} {% endif %}
</div> </div>
<!-- Subspecialty --> <!-- Subspecialty -->
<div class="m2m-group" data-model="Subspecialty"><strong>Subspecialty:</strong> <div class="m2m-group" data-model="Subspecialty"><strong>Subspecialty:</strong>
{% if item.resolved_m2m.subspecialty %} {% if item.resolved_m2m.subspecialty %}
{% for val in item.resolved_m2m.subspecialty %} {% for val in item.resolved_m2m.subspecialty %}
<button type="button" class="m2m-toggle btn btn-sm btn-success ms-1" data-idx="{{ item.index }}" data-model="Subspecialty" data-value="{{ val }}">{{ val }}</button> <button type="button" class="m2m-toggle btn btn-sm btn-success ms-1" data-idx="{{ item.index }}" data-model="Subspecialty" data-value="{{ val }}">{{ val }}</button>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
{% if item.missing_map.subspecialty %} {% if item.missing_map.subspecialty %}
{% for val in item.missing_map.subspecialty %} {% for val in item.missing_map.subspecialty %}
<button type="button" class="m2m-toggle btn btn-sm btn-success ms-1" data-idx="{{ item.index }}" data-model="Subspecialty" data-value="{{ val }}">{{ val }}</button> <button type="button" class="m2m-toggle btn btn-sm btn-success ms-1" data-idx="{{ item.index }}" data-model="Subspecialty" data-value="{{ val }}">{{ val }}</button>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
<span class="text-muted m2m-none" style="display:none">(none)</span> <span class="text-muted m2m-none" style="display:none">(none)</span>
{% if item.missing_map.subspecialty %} {% if item.missing_map.subspecialty %}
<div class="small text-info">Would create: {{ item.missing_map.subspecialty|join:", " }}</div> <div class="small text-info">Would create: {{ item.missing_map.subspecialty|join:", " }}</div>
{% endif %} {% endif %}
</div> </div>
<! -- Category --> <! -- Category -->
<div class="m2m-group" data-model="Category"><strong>Category:</strong> <div class="m2m-group" data-model="Category"><strong>Category:</strong>
{{ item.payload.category|default:'(none)' }} {{ item.payload.category|default:'(none)' }}
</div> </div>
<!-- DEBUG: show raw resolved and missing maps for troubleshooting --> <!-- DEBUG: show raw resolved and missing maps for troubleshooting -->
<div class="card-footer text-monospace small"> <div class="card-footer text-monospace small">
<details> <details>
<summary>Debug: resolved_m2m / missing_map (click to expand)</summary> <summary>Debug: resolved_m2m / missing_map (click to expand)</summary>
<pre style="white-space:pre-wrap;">resolved_m2m: {{ item.resolved_m2m|default:"{}" }} <pre style="white-space:pre-wrap;">resolved_m2m: {{ item.resolved_m2m|default:"{}" }}
missing_map: {{ item.missing_map|default:"{}" }}</pre> missing_map: {{ item.missing_map|default:"{}" }}</pre>
</details> </details>
</div> </div>
<div> <div>
<label><input type="checkbox" name="selected" value="{{ item.index }}" checked> Include</label> <label><input type="checkbox" name="selected" value="{{ item.index }}" checked> Include</label>
</div> </div>
<!-- container for hidden exclude inputs for this item --> <!-- container for hidden exclude inputs for this item -->
<div id="exclude-container-{{ item.index }}"></div> <div id="exclude-container-{{ item.index }}"></div>
</div>
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
@@ -235,3 +268,22 @@ missing_map: {{ item.missing_map|default:"{}" }}</pre>
</script> </script>
<!-- Bottom controls removed: submit and cancel are inside the form above --> <!-- 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>
+211 -123
View File
@@ -1,129 +1,217 @@
{% extends 'sbas/base.html' %} {% extends 'sbas/base.html' %}
{% block content %} {% load partials %}
<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>
{% 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 %}
</li>
<li><strong>{{ question.b_answer }}</strong>
{% if question.b_feedback %}<p>Feedback:<i>{{ question.b_feedback|default:''|safe }}</i> </p>{% endif %}
</li>
<li><strong>{{ question.c_answer }}</strong>
{% if question.c_feedback %}<p>Feedback:<i>{{ question.c_feedback|default:''|safe }}</i> </p>{% endif %}
</li>
<li><strong>{{ question.d_answer }}</strong>
{% if question.d_feedback %}<p>Feedback:<i>{{ question.d_feedback|default:''|safe }}</i> </p>{% endif %}
</li>
<li><strong>{{ question.e_answer }}</strong>
{% if question.e_feedback %}<p>Feedback:<i>{{ question.e_feedback|default:''|safe }}</i> </p>{% endif %}
</li>
</ol>
Best answer: {{ question.best_answer }} ({{question.get_correct_answer_stripped}})
</div>
{% endautoescape %}
<div>
Feedback: {{ question.feedback|linebreaks }}
</div>
<div>
Sources:
{% if question.sources %}
<ul>
{% for source in question.sources %}
<li>{{ 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
{% 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> {% partialdef links-partial %}
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>
<div class="btn-group mt-3 mt-md-0" role="group" aria-label="Actions">
{% include 'question_notes.html' %} <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> </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="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 %}
<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 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 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 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 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>
<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>
<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 class="list-unstyled">
{% for source in question.sources %}
<li class="small">{{ source }}</li>
{% endfor %}
</ul>
{% else %}
<div class="small text-muted">None</div>
{% 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 %} {% endblock %}
+18 -11
View File
@@ -1,23 +1,30 @@
{% extends "sbas/base.html" %} {% extends "sbas/base.html" %}
{% load static %} {% load static %}
{% load partials %}
{% block js %} {% block js %}
<!--<script type="text/javascript" src="/admin/jsi18n/"></script>--> <!--<script type="text/javascript" src="/admin/jsi18n/"></script>-->
<script type="text/javascript"> <script type="text/javascript">
</script> </script>
{{ form.media }} {{ form.media }}
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<h2>Submit Question</h2> <div class="d-flex justify-content-between align-items-start mb-3 flex-column flex-md-row">
<form action="" method="post" enctype="multipart/form-data" id="anatomyquestion-form"> <div>
{% csrf_token %} <h2>Submit Question</h2>
</div>
{% include "sbas/question_detail.html#links-partial" %}
</div>
<table> <form action="" method="post" enctype="multipart/form-data" id="anatomyquestion-form">
{{ form.as_table }} {% csrf_token %}
</table>
<input type="submit" class="submit-button" value="Submit" name="submit"> <table>
</form> {{ form.as_table }}
</table>
<input type="submit" class="submit-button" value="Submit" name="submit">
</form>
{% endblock %} {% endblock %}
+31 -12
View File
@@ -5,18 +5,37 @@
{% block content %} {% block content %}
<div id="view-filter-options"> <div id="view-filter-options">
<h3>Filter SBA Questions</h3> <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 }} <div class="col-12 col-lg-9">
<div class="table-responsive">
<input class="btn btn-primary btn-sm mt-1 mb-1" type="submit" /> {% autoescape off %}
</form> {% load render_table from django_tables2 %}
</div> {% render_table table %}
{% autoescape off %} {% endautoescape %}
{% load render_table from django_tables2 %} </div>
{% render_table table %} </div>
{% endautoescape %} </div>
{% endblock %} {% endblock %}
+1 -1
View File
@@ -9,7 +9,7 @@ app_name = "sbas"
urlpatterns = [ urlpatterns = [
path("question/", views.QuestionView.as_view(), name="question_view"), 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/create/", views.QuestionCreate.as_view(), name="question_create"),
path( path(
"question/<int:pk>/update", "question/<int:pk>/update",
+102 -13
View File
@@ -98,10 +98,10 @@ class AuthorOrCheckerRequiredMixin(object):
# return render(request, "sbas/question_view.html", {"questions": questions}) # return render(request, "sbas/question_view.html", {"questions": questions})
@login_required #@login_required
def question_detail(request, pk): #def question_detail(request, pk):
question = get_object_or_404(Question, pk=pk) # question = get_object_or_404(Question, pk=pk)
return render(request, "sbas/question_detail.html", {"question": question}) # return render(request, "sbas/question_detail.html", {"question": question})
def active_exams(request): def active_exams(request):
@@ -487,6 +487,27 @@ def _resolve_m2m(model_class, value):
return list(qs[:5]) return list(qs[:5])
return [] 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 @login_required
@user_passes_test(lambda u: u.is_superuser) @user_passes_test(lambda u: u.is_superuser)
@@ -570,6 +591,7 @@ def import_llm_questions(request):
new_s, n = pattern.subn(r"\\\\", s) new_s, n = pattern.subn(r"\\\\", s)
return new_s, n return new_s, n
raw_text_stripped = raw_text.strip() raw_text_stripped = raw_text.strip()
raw_text, sanitized_count = _sanitize_backslashes(raw_text_stripped) 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) logger.debug("import_llm_questions: sanitized input length %d (escaped %d backslashes)", len(raw_text), sanitized_count)
@@ -637,14 +659,61 @@ def import_llm_questions(request):
for idx, payload in enumerate(candidates): for idx, payload in enumerate(candidates):
item_report = {"index": idx, "status": None, "errors": [], "missing_m2m": [], "resolved_m2m": {}, "payload": payload} 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) # Strip numeric bracket references from feedback fields (e.g., [1], [1,2]) and record which fields were changed
if item_report["errors"]: cleaned_payload, stripped = _strip_numeric_refs_in_payload(payload)
logger.debug(f"Validation errors for item {idx}: {item_report['errors']}") if stripped:
report["errors"].append({"index": idx, "errors": item_report["errors"]}) item_report['stripped_fields'] = stripped
item_report["status"] = "invalid" payload = cleaned_payload
report["per_item"].append(item_report)
continue # 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: try:
# Prepare category resolution # Prepare category resolution
@@ -706,6 +775,9 @@ def import_llm_questions(request):
list((item_report.get("resolved_m2m") or {}).keys()), 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, do not save; just report what would happen
if dry_run: if dry_run:
item_report["status"] = "would_create" item_report["status"] = "would_create"
@@ -878,7 +950,24 @@ def import_llm_confirm(request):
except Exception: except Exception:
errors.append({"index": idx, "errors": ["Missing candidate"]}) errors.append({"index": idx, "errors": ["Missing candidate"]})
continue 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: try:
with transaction.atomic(): with transaction.atomic():
q = Question() 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>
+194 -27
View File
@@ -1,39 +1,206 @@
{% extends app_name|add:'/base.html' %} {% extends app_name|add:'/base.html' %}
{% load crispy_forms_tags %}
{% load render_table from django_tables2 %} {% load render_table from django_tables2 %}
{% block css %}
{% endblock %}
{% block content %} {% block content %}
<div id="view-filter-options"> <div class="row mb-3">
<details class="filter"> <div class="col-12">
<summary> <div class="d-flex justify-content-between mb-2 align-items-center flex-column flex-md-row">
<h3>Filter questions</h3> <div class="mb-2 mb-md-0">
</summary> <div class="btn-toolbar" role="toolbar">
<form action="" method="get"> <div class="btn-group me-2" role="group">
{{ filter.form }} <button id="delete-selected-btn" class="btn btn-danger btn-sm"
<input class="btn btn-primary btn-sm mt-1 mb-1" type="submit" /> hx-post="{% url 'generic:bulk_delete_questions' %}"
</form> hx-include="[name='selection']:checked"
</details> 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</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>
<div class="table-responsive">
{% render_table table %}
</div>
</div>
</div> </div>
{% render_table table %}
<button id="delete-selected-btn" class="btn btn-danger btn-sm mt-2"
hx-post="{% url 'generic:bulk_delete_questions' %}"
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>
<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 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 %} {% endblock %}