Compare commits
31
Commits
4986c2faa9
...
4f47354ca1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f47354ca1 | ||
|
|
bc21dd6ebd | ||
|
|
5d09c34f6d | ||
|
|
7e7a1b8763 | ||
|
|
272c3381e1 | ||
|
|
4a3a089941 | ||
|
|
aac4146b7f | ||
|
|
42b64c11ff | ||
|
|
2a94e40a1e | ||
|
|
4d305e1fa2 | ||
|
|
452143f1ef | ||
|
|
264270ce00 | ||
|
|
980bb199cf | ||
|
|
1085b9e6a2 | ||
|
|
52886d176b | ||
|
|
4ad101d65e | ||
|
|
def291c853 | ||
|
|
9dd4fc0402 | ||
|
|
8f5ce830fe | ||
|
|
b680ae749f | ||
|
|
441d33a382 | ||
|
|
bddccd5768 | ||
|
|
88713aba5b | ||
|
|
c577da4779 | ||
|
|
16387fff77 | ||
|
|
d37376d998 | ||
|
|
80171da78d | ||
|
|
9be48140a4 | ||
|
|
01f67f3e32 | ||
|
|
06b7239daa | ||
|
|
93f0bb8927 |
@@ -23,64 +23,64 @@
|
||||
View my <a href='{% url "atlas:case_view" %}?author={{request.user.id}}'>cases</a>.
|
||||
</details>
|
||||
<form>
|
||||
<details id="actions-detail" class="mt-3">
|
||||
<summary>
|
||||
<strong>Actions</strong>
|
||||
</summary>
|
||||
<div class="row mb-2">
|
||||
<div class="col-auto">
|
||||
<select id="collection-select" name="collection_id" class="form-select form-select-sm">
|
||||
<details id="actions-detail" class="mt-3">
|
||||
<summary>
|
||||
<strong>Actions</strong>
|
||||
</summary>
|
||||
<div class="row mb-2">
|
||||
<div class="col-auto">
|
||||
<select id="collection-select" name="collection_id" class="form-select form-select-sm">
|
||||
<!-- Dynamically load collection options via HTMX -->
|
||||
<option value="">Loading collections...</option>
|
||||
<option hx-trigger="every 1s[document.getElementById('actions-detail').open] once" hx-get="{% url 'atlas:collection_options' %}" hx-target="#collection-select" hx-swap="innerHTML"></option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button id="add-to-collection-btn"
|
||||
class="btn btn-sm btn-outline-primary"
|
||||
hx-post="{% url 'atlas:add_cases_to_collection' %}"
|
||||
hx-include="[name='selection']:checked, #collection-select"
|
||||
hx-target="#action-result"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
Add selected cases to collection
|
||||
</button>
|
||||
<button id="remove-from-collection-btn"
|
||||
class="btn btn-sm btn-outline-danger ms-2"
|
||||
hx-post="{% url 'atlas:remove_cases_from_collection' %}"
|
||||
hx-include="[name='selection']:checked, #collection-select"
|
||||
hx-target="#action-result"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
Remove selected cases from collection
|
||||
</button>
|
||||
<br/>
|
||||
<button id="set-open-access-btn"
|
||||
class="btn btn-sm btn-outline-success ms-2"
|
||||
hx-post="{% url 'atlas:set_open_access' %}"
|
||||
hx-include="[name='selection']:checked"
|
||||
hx-target="#action-result"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
Set Open Access
|
||||
</button>
|
||||
<button id="remove-open-access-btn"
|
||||
class="btn btn-sm btn-outline-warning ms-2"
|
||||
hx-post="{% url 'atlas:remove_open_access' %}"
|
||||
hx-include="[name='selection']:checked"
|
||||
hx-target="#action-result"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
Remove Open Access
|
||||
</button>
|
||||
<span id="action-result"></span>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
{% render_table table %}
|
||||
</form>
|
||||
<option value="">Loading collections...</option>
|
||||
<option hx-trigger="every 1s[document.getElementById('actions-detail').open] once" hx-get="{% url 'atlas:collection_options' %}" hx-target="#collection-select" hx-swap="innerHTML"></option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button id="add-to-collection-btn"
|
||||
class="btn btn-sm btn-outline-primary"
|
||||
hx-post="{% url 'atlas:add_cases_to_collection' %}"
|
||||
hx-include="[name='selection']:checked, #collection-select"
|
||||
hx-target="#action-result"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
Add selected cases to collection
|
||||
</button>
|
||||
<button id="remove-from-collection-btn"
|
||||
class="btn btn-sm btn-outline-danger ms-2"
|
||||
hx-post="{% url 'atlas:remove_cases_from_collection' %}"
|
||||
hx-include="[name='selection']:checked, #collection-select"
|
||||
hx-target="#action-result"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
Remove selected cases from collection
|
||||
</button>
|
||||
<br/>
|
||||
<button id="set-open-access-btn"
|
||||
class="btn btn-sm btn-outline-success ms-2"
|
||||
hx-post="{% url 'atlas:set_open_access' %}"
|
||||
hx-include="[name='selection']:checked"
|
||||
hx-target="#action-result"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
Set Open Access
|
||||
</button>
|
||||
<button id="remove-open-access-btn"
|
||||
class="btn btn-sm btn-outline-warning ms-2"
|
||||
hx-post="{% url 'atlas:remove_open_access' %}"
|
||||
hx-include="[name='selection']:checked"
|
||||
hx-target="#action-result"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
Remove Open Access
|
||||
</button>
|
||||
<span id="action-result"></span>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
{% render_table table %}
|
||||
</form>
|
||||
|
||||
{% include "generic/partials/page_size_form.html" %}
|
||||
{% include "generic/partials/page_size_form.html" %}
|
||||
|
||||
<div id="exam-options"></div>
|
||||
<div id="exam-options"></div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -62,25 +62,34 @@
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% if condition.sbas_questions.all %}
|
||||
<h4>Related SBA Questions</h4>
|
||||
<ul>
|
||||
{% for q in condition.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 %}
|
||||
|
||||
|
||||
{% if not condition.rcr_curriculum and can_merge %}
|
||||
|
||||
<details><summary>Merge</summary>
|
||||
Merge this condition into another. This will transfer all associated cases (and differential) but will not transfer synonymns / parents or children.
|
||||
<form method="POST">
|
||||
{% csrf_token %}
|
||||
|
||||
<details><summary>Merge</summary>
|
||||
Merge this condition into another. This will transfer all associated cases (and differential) but will not transfer synonymns / parents or children.
|
||||
<form method="POST">
|
||||
{% csrf_token %}
|
||||
<table>
|
||||
{{ form.as_table }}
|
||||
{{ form.as_table }}
|
||||
</table>
|
||||
<button hx-post='{% url "atlas:condition_merge" condition.pk %}'
|
||||
hx-target="#merge-options" hx-swap="innerHTML"
|
||||
hx-confirm="This will merge {{condition.name}} into the selecetd condition, this cannot be undone. Continue?"
|
||||
>
|
||||
Merge
|
||||
</button>
|
||||
</form>
|
||||
<div id="merge-options"></div>
|
||||
</details>
|
||||
<button hx-post='{% url "atlas:condition_merge" condition.pk %}'
|
||||
hx-target="#merge-options" hx-swap="innerHTML"
|
||||
hx-confirm="This will merge {{condition.name}} into the selecetd condition, this cannot be undone. Continue?"
|
||||
>
|
||||
Merge
|
||||
</button>
|
||||
</form>
|
||||
<div id="merge-options"></div>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<div class="alert alert-info">Deleted {{ deleted }} items.</div>
|
||||
{% if errors %}
|
||||
<div class="alert alert-warning">Errors: {{ errors }}</div>
|
||||
{% endif %}
|
||||
@@ -34,6 +34,7 @@ urlpatterns = [
|
||||
views.generic_exam_json_edit,
|
||||
name="generic_exam_json_edit",
|
||||
),
|
||||
path("bulk_delete_questions/", views.bulk_delete_questions, name="bulk_delete_questions"),
|
||||
path(
|
||||
"examination-autocomplete",
|
||||
views.ExaminationAutocomplete.as_view(
|
||||
|
||||
@@ -149,6 +149,83 @@ from django.db.models import Prefetch
|
||||
from loguru import logger
|
||||
|
||||
|
||||
@login_required
|
||||
@user_passes_test(lambda u: u.is_superuser)
|
||||
def bulk_delete_questions(request):
|
||||
"""Generic bulk delete for question-like models.
|
||||
|
||||
Expects POST with 'app' (app_name) and 'selected' (list or comma-separated ids).
|
||||
Returns a small HTML fragment summarising deletions (suitable for HTMX).
|
||||
"""
|
||||
if request.method != "POST":
|
||||
return HttpResponse(status=405)
|
||||
|
||||
logger.debug(f"bulk_delete_questions called with POST: {request.POST}")
|
||||
|
||||
app = request.POST.get("app")
|
||||
|
||||
# Accept different submission patterns: checkboxes may be sent as 'selection' or 'selected'
|
||||
selected_list = []
|
||||
# common variants
|
||||
for key in ("selected", "selection", "selected[]", "selection[]"):
|
||||
vals = request.POST.getlist(key)
|
||||
if vals:
|
||||
# extend list; we'll parse ints below
|
||||
selected_list.extend(vals)
|
||||
|
||||
# Also accept a raw CSV string under 'selected'
|
||||
selected_raw = request.POST.get("selected")
|
||||
|
||||
ids = set()
|
||||
if selected_list:
|
||||
for s in selected_list:
|
||||
# some checkbox libraries may give empty strings or 'on' for checked boxes
|
||||
try:
|
||||
ids.add(int(s))
|
||||
except Exception:
|
||||
# ignore non-int vals
|
||||
continue
|
||||
elif selected_raw:
|
||||
for part in selected_raw.split(','):
|
||||
try:
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
ids.add(int(part))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Map app name to model class
|
||||
APP_MODEL_MAP = {
|
||||
"sbas": SbasQuestion,
|
||||
"physics": PhysicsQuestion,
|
||||
"anatomy": AnatomyQuestion,
|
||||
"rapids": RapidQuestion,
|
||||
"rapid": RapidQuestion,
|
||||
"shorts": ShortsQuestion,
|
||||
"longs": LongQuestion,
|
||||
"long": LongQuestion,
|
||||
}
|
||||
|
||||
Model = APP_MODEL_MAP.get(app)
|
||||
if Model is None:
|
||||
return JsonResponse({"ok": False, "error": f"Unknown app: {app}"}, status=400)
|
||||
|
||||
deleted = 0
|
||||
errors = []
|
||||
try:
|
||||
qs = Model.objects.filter(pk__in=list(ids))
|
||||
deleted = qs.count()
|
||||
qs.delete()
|
||||
except Exception as e:
|
||||
logger.exception("bulk_delete failed for app=%s ids=%s", app, ids)
|
||||
return JsonResponse({"ok": False, "error": str(e)}, status=500)
|
||||
|
||||
# Render a small fragment summarising deletions
|
||||
context = {"deleted": deleted, "errors": errors}
|
||||
return render(request, "generic/partials/bulk_delete_result.html", context)
|
||||
|
||||
|
||||
class RedirectMixin:
|
||||
def get_success_url(self) -> str:
|
||||
if "redirect" in self.request.GET:
|
||||
|
||||
+31
-10
@@ -3,6 +3,7 @@ from django.forms import (
|
||||
ModelForm,
|
||||
ModelMultipleChoiceField,
|
||||
ModelChoiceField,
|
||||
CheckboxSelectMultiple,
|
||||
ChoiceField,
|
||||
CharField,
|
||||
)
|
||||
@@ -22,6 +23,8 @@ from django.forms.widgets import RadioSelect, TextInput, Textarea
|
||||
|
||||
from tinymce.widgets import TinyMCE
|
||||
|
||||
from dal import autocomplete
|
||||
|
||||
|
||||
class UserAnswerForm(ModelForm):
|
||||
class Meta:
|
||||
@@ -140,6 +143,11 @@ class QuestionForm(ModelForm):
|
||||
"e_answer",
|
||||
"e_feedback",
|
||||
"best_answer",
|
||||
"finding",
|
||||
"structure",
|
||||
"condition",
|
||||
"presentation",
|
||||
"subspecialty",
|
||||
]
|
||||
|
||||
widgets = {
|
||||
@@ -148,16 +156,29 @@ class QuestionForm(ModelForm):
|
||||
# (False, 'No')])
|
||||
"stem" : TinyMCE(attrs={'cols': 80, 'rows': 30}),
|
||||
"feedback" : TinyMCE(attrs={'cols': 80, 'rows': 30}),
|
||||
"a_answer" : TinyMCE(attrs={'cols': 80, 'rows': 5}, mce_attrs={'height': 140}),
|
||||
"a_feedback" : TinyMCE(attrs={'cols': 80, 'rows': 5}, mce_attrs={'height': 140}),
|
||||
"b_answer" : TinyMCE(attrs={'cols': 80, 'rows': 4}, mce_attrs={'height': 140}),
|
||||
"c_answer" : TinyMCE(attrs={'cols': 80, 'rows': 4}, mce_attrs={'height': 140}),
|
||||
"d_answer" : TinyMCE(attrs={'cols': 80, 'rows': 4}, mce_attrs={'height': 140}),
|
||||
"e_answer" : TinyMCE(attrs={'cols': 80, 'rows': 4}, mce_attrs={'height': 140}),
|
||||
"b_feedback" : TinyMCE(attrs={'cols': 80, 'rows': 4}, mce_attrs={'height': 140}),
|
||||
"c_feedback" : TinyMCE(attrs={'cols': 80, 'rows': 4}, mce_attrs={'height': 140}),
|
||||
"d_feedback" : TinyMCE(attrs={'cols': 80, 'rows': 4}, mce_attrs={'height': 140}),
|
||||
"e_feedback" : TinyMCE(attrs={'cols': 80, 'rows': 4}, mce_attrs={'height': 140}),
|
||||
"a_answer" : TinyMCE(attrs={'cols': 80, 'rows': 5}, mce_attrs={'height': 200}),
|
||||
"a_feedback" : TinyMCE(attrs={'cols': 80, 'rows': 5}, mce_attrs={'height': 200}),
|
||||
"b_answer" : TinyMCE(attrs={'cols': 80, 'rows': 4}, mce_attrs={'height': 200}),
|
||||
"c_answer" : TinyMCE(attrs={'cols': 80, 'rows': 4}, mce_attrs={'height': 200}),
|
||||
"d_answer" : TinyMCE(attrs={'cols': 80, 'rows': 4}, mce_attrs={'height': 200}),
|
||||
"e_answer" : TinyMCE(attrs={'cols': 80, 'rows': 4}, mce_attrs={'height': 200}),
|
||||
"b_feedback" : TinyMCE(attrs={'cols': 80, 'rows': 4}, mce_attrs={'height': 200}),
|
||||
"c_feedback" : TinyMCE(attrs={'cols': 80, 'rows': 4}, mce_attrs={'height': 200}),
|
||||
"d_feedback" : TinyMCE(attrs={'cols': 80, 'rows': 4}, mce_attrs={'height': 200}),
|
||||
"e_feedback" : TinyMCE(attrs={'cols': 80, 'rows': 4}, mce_attrs={'height': 200}),
|
||||
"structure": autocomplete.ModelSelect2Multiple(
|
||||
url="atlas:structure-autocomplete"
|
||||
),
|
||||
"finding": autocomplete.ModelSelect2Multiple(
|
||||
url="atlas:finding-autocomplete"
|
||||
),
|
||||
"condition": autocomplete.ModelSelect2Multiple(
|
||||
url="atlas:condition-autocomplete"
|
||||
),
|
||||
"presentation": autocomplete.ModelSelect2Multiple(
|
||||
url="atlas:presentation-autocomplete"
|
||||
),
|
||||
"subspecialty": CheckboxSelectMultiple(),
|
||||
}
|
||||
|
||||
#widgets = {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# Generated by Django 5.1.4 on 2025-10-20 08:47
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0079_casecollection_prerequisites'),
|
||||
('sbas', '0017_exam_results_supervisor_visible'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='question',
|
||||
name='condition',
|
||||
field=models.ManyToManyField(blank=True, related_name='sbas_questions', to='atlas.condition'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='question',
|
||||
name='finding',
|
||||
field=models.ManyToManyField(blank=True, related_name='sbas_questions', to='atlas.finding'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='question',
|
||||
name='presentation',
|
||||
field=models.ManyToManyField(blank=True, related_name='sbas_questions', to='atlas.presentation'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='question',
|
||||
name='structure',
|
||||
field=models.ManyToManyField(blank=True, related_name='sbas_questions', to='atlas.structure'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='question',
|
||||
name='subspecialty',
|
||||
field=models.ManyToManyField(blank=True, related_name='sbas_questions', to='atlas.subspecialty'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.4 on 2025-10-20 09:30
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('sbas', '0018_question_condition_question_finding_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='question',
|
||||
name='title',
|
||||
field=models.CharField(blank=True, help_text='Short title for question', max_length=200, null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.4 on 2025-10-20 11:03
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('sbas', '0019_question_title'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='question',
|
||||
name='sources',
|
||||
field=models.JSONField(blank=True, help_text='List of source references for the question (e.g. article DOIs or StatDx IDs or Source file names).', null=True),
|
||||
),
|
||||
]
|
||||
@@ -15,6 +15,8 @@ import reversion
|
||||
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
|
||||
from atlas.models import Finding, Structure, Condition, Subspecialty, Presentation
|
||||
|
||||
|
||||
class Category(models.Model):
|
||||
category = models.CharField(max_length=200)
|
||||
@@ -24,6 +26,7 @@ class Category(models.Model):
|
||||
|
||||
|
||||
class Question(QuestionBase):
|
||||
title = models.CharField(max_length=200, help_text="Short title for question", blank=True, null=True)
|
||||
stem = models.TextField(
|
||||
blank=False,
|
||||
help_text="Stem of the question",
|
||||
@@ -91,6 +94,18 @@ class Question(QuestionBase):
|
||||
Category, on_delete=models.SET_NULL, null=True, blank=True
|
||||
)
|
||||
|
||||
sources = models.JSONField(
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="List of source references for the question (e.g. article DOIs or StatDx IDs or Source file names).",
|
||||
)
|
||||
|
||||
finding = models.ManyToManyField(Finding, blank=True, related_name="sbas_questions")
|
||||
structure = models.ManyToManyField(Structure, blank=True, related_name="sbas_questions")
|
||||
condition = models.ManyToManyField(Condition, 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")
|
||||
|
||||
#notes = GenericRelation(QuestionNote)
|
||||
|
||||
def __str__(self):
|
||||
|
||||
+20
-4
@@ -17,13 +17,17 @@ class QuestionTable(tables.Table):
|
||||
#delete = tables.LinkColumn(
|
||||
# "sbas:question_delete", text="Delete", args=[A("pk")], orderable=False
|
||||
#)
|
||||
exams = tables.ManyToManyColumn(verbose_name="Exams")
|
||||
#exams = tables.ManyToManyColumn(verbose_name="Exams")
|
||||
|
||||
selection = tables.CheckBoxColumn(accessor="pk", orderable=False)
|
||||
|
||||
stem = tables.Column()
|
||||
answers = tables.TemplateColumn("<ol type='A'><li>{{ record.a_answer|safe}}</li><li>{{ record.b_answer|safe}}</li><li>{{ record.c_answer|safe}}</li><li>{{ record.d_answer|safe}}</li><li>{{ record.e_answer|safe}}</li></ol>")
|
||||
|
||||
edit = tables.LinkColumn(
|
||||
"sbas:question_update", text="Edit", args=[A("pk")], orderable=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Question
|
||||
template_name = "django_tables2/bootstrap4.html"
|
||||
@@ -38,19 +42,31 @@ class QuestionTable(tables.Table):
|
||||
"created_date",
|
||||
"category",
|
||||
"author",
|
||||
"edit"
|
||||
)
|
||||
sequence = ("view", "stem", "answers", "exams")
|
||||
sequence = ("view", "stem", "answers")#, "exams")
|
||||
attrs = {"class": "table row-selector"}
|
||||
order_by = "-created_date"
|
||||
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
super().__init__(
|
||||
data.prefetch_related(
|
||||
"category", "exams", "author"
|
||||
"category", "author"
|
||||
),
|
||||
*args,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def render_stem(self, value):
|
||||
def render_stem(self, value, record=None, **kwargs):
|
||||
"""Render the stem, optionally with a title from the record.
|
||||
|
||||
Some versions of django-tables2 may call the render method without
|
||||
passing a `record`; guard for that and fall back to rendering the
|
||||
value only.
|
||||
"""
|
||||
title = getattr(record, "title", None) if record is not None else None
|
||||
if title:
|
||||
return format_html("<h5>{}</h5>{}", title, mark_safe(value))
|
||||
return mark_safe(value)
|
||||
|
||||
|
||||
|
||||
@@ -27,6 +27,13 @@
|
||||
<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">
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{% extends 'sbas/base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Import LLM Questions</h1>
|
||||
<form id="llm-import-form" method="post" enctype="multipart/form-data" hx-post="{% url 'sbas:import_llm_questions' %}" hx-target="#import-preview" hx-swap="innerHTML">
|
||||
{% csrf_token %}
|
||||
{{ form.as_p }}
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-outline-primary" type="submit">Preview (dry run)</button>
|
||||
<button class="btn btn-secondary" type="button" onclick="document.getElementById('llm-import-form').reset()">Reset</button>
|
||||
</div>
|
||||
</form>
|
||||
<p>Upload a single JSON array, a single object, or JSONL (one JSON object per line) matching the agreed schema. Use the Preview button to inspect results before importing.</p>
|
||||
<div id="import-preview" class="mt-3"></div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,142 @@
|
||||
{% extends 'sbas/base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<h2>LLM Prompts</h2>
|
||||
<p>
|
||||
Below are the prompts used for interacting with large language models (LLMs) within the SBA system.
|
||||
</p>
|
||||
|
||||
<h3>Question Generation Prompt</h3>
|
||||
<pre>
|
||||
You are an expert radiologist tasked with generating high-quality multiple-choice questions for medical imaging education. Each question should consist of a clinical vignette, an image description, and five answer choices (A through E), with one correct answer and four plausible distractors.
|
||||
|
||||
Please adhere to the following guidelines when creating each question:
|
||||
1. Clinical Vignette: Provide a brief clinical scenario that sets the context for the question including relevant patient history and clinical findings.
|
||||
2. Make sure the question focuses on key radiological concepts, findings, or diagnoses.
|
||||
3. Answer Choices: Create five answer options. Ensure that one is the correct answer and the others are plausible distractors.
|
||||
4. Explanation: Provide a concise explanation for why the correct answer is right and why the distractors are incorrect.
|
||||
5. Difficulty Level: Aim for a moderate difficulty level suitable for training radiologists.
|
||||
6. Clarity and Precision: Use clear and precise language, avoiding ambiguity.
|
||||
7. Relevance: Ensure that the question is relevant to current radiological practices and guidelines.
|
||||
8. Questions should feature metadata tags for finding, structure, condition, presentation, and subspecialty.
|
||||
9. Each question output should be a json object with the following schema:
|
||||
10. For article sources pleese reference the article doi within the explanation field in the format [doi:10.xxxx/xxxxx], if you do not know the doi please use the article name.
|
||||
11. For statdx sources please reference the article name in the format [statdx:article_id] (article_id may be docid).
|
||||
12. References should be included inline within the feedback fields.
|
||||
13. Sources can also be included in the sources field as a list.
|
||||
14. Questions need to have a radiology focus, please avoid questions that are purely clinical or biochemical without imaging relevance.
|
||||
|
||||
JSON Schema (draft-07 compatible)
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "SBA Question",
|
||||
"description": "Schema for importing LLM-generated questions into the sbas.Question model.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"title",
|
||||
"stem",
|
||||
"a_answer",
|
||||
"b_answer",
|
||||
"c_answer",
|
||||
"d_answer",
|
||||
"e_answer",
|
||||
"best_answer",
|
||||
"category"
|
||||
],
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Short title for the question."
|
||||
},
|
||||
"stem": {
|
||||
"type": "string",
|
||||
"description": "HTML or plain text question stem. Trim leading/trailing whitespace before saving."
|
||||
},
|
||||
"a_answer": { "type": "string" },
|
||||
"a_feedback": { "type": "string", "default": "" },
|
||||
"b_answer": { "type": "string" },
|
||||
"b_feedback": { "type": "string", "default": "" },
|
||||
"c_answer": { "type": "string" },
|
||||
"c_feedback": { "type": "string", "default": "" },
|
||||
"d_answer": { "type": "string" },
|
||||
"d_feedback": { "type": "string", "default": "" },
|
||||
"e_answer": { "type": "string" },
|
||||
"e_feedback": { "type": "string", "default": "" },
|
||||
"feedback": {
|
||||
"type": "string",
|
||||
"description": "General question feedback (may include HTML)."
|
||||
},
|
||||
"best_answer": {
|
||||
"type": "string",
|
||||
"enum": ["a", "b", "c", "d", "e"],
|
||||
"description": "One of 'a','b','c','d','e'."
|
||||
},
|
||||
"category": {
|
||||
"oneOf": [
|
||||
{ "type": "string", "description": "Category name ('Central Nervous and Head & Neck', 'Paediatric', 'Genito-urinary, Adrenal, Obstetrics & Gynaecology and Breast', 'Gastro-intestinal', 'Musculoskeletal and Trauma', 'Cardiothoracic and Vascular')" }
|
||||
]
|
||||
},
|
||||
"sources": {
|
||||
"type": "array",
|
||||
"description": "List of source references for the question (e.g. article DOIs or docid or StatDx IDs or Source file names).",
|
||||
"items": { "type": "string" },
|
||||
"uniqueItems": true
|
||||
},
|
||||
"finding": {
|
||||
"type": "array",
|
||||
"description": "References to Finding objects. Prefer numeric IDs; names allowed if importer resolves them.",
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{ "type": "integer" },
|
||||
{ "type": "string" }
|
||||
]
|
||||
},
|
||||
"uniqueItems": true
|
||||
},
|
||||
"structure": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{ "type": "integer" },
|
||||
{ "type": "string" }
|
||||
]
|
||||
},
|
||||
"uniqueItems": true
|
||||
},
|
||||
"condition": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{ "type": "integer" },
|
||||
{ "type": "string" }
|
||||
]
|
||||
},
|
||||
"uniqueItems": true
|
||||
},
|
||||
"presentation": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{ "type": "integer" },
|
||||
{ "type": "string" }
|
||||
]
|
||||
},
|
||||
"uniqueItems": true
|
||||
},
|
||||
"subspecialty": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{ "type": "integer" },
|
||||
{ "type": "string", "description": "Subspecialty name (e.g. 'Haematology & Oncology', 'Vascular', 'Uroradiology', 'Thoracic', 'Paediatric', 'Obstetric and Gynaecological', 'Neuroradiology', 'Musculoskeletal', 'Head and Neck', 'Gastrointestinal and hepatobiliary', 'Cardiac', 'Breast')" }
|
||||
]
|
||||
},
|
||||
"uniqueItems": true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
</pre>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,8 @@
|
||||
<div class="alert alert-danger">
|
||||
<h5>Import error</h5>
|
||||
<ul>
|
||||
{% for e in errors %}
|
||||
<li>{{ e }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -0,0 +1,237 @@
|
||||
<form id="llm-import-confirm-form" method="post" hx-post="{% url 'sbas:import_llm_confirm' %}" hx-target="#import-result" hx-swap="innerHTML">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="session_key" value="{{ session_key }}">
|
||||
{% for item in items %}
|
||||
<div class="card mb-2">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Question {{ item.index|add:1 }}</h5>
|
||||
{% if item.payload %}
|
||||
<p><strong>Title:</strong> {{ item.payload.title|default:'(No title)'|safe }}</p>
|
||||
<p><strong>Stem:</strong> {{ item.payload.stem|default:''|safe }}</p>
|
||||
<p><strong>Answers:</strong>
|
||||
<ul>
|
||||
<li>A: {{ item.payload.a_answer|default:''|safe }}</li>
|
||||
{% if item.payload.a_feedback %}
|
||||
<p><strong>A Feedback:</strong> {{ item.payload.a_feedback|default:''|safe }}</p>
|
||||
{% endif %}
|
||||
<li>B: {{ item.payload.b_answer|default:''|safe }}</li>
|
||||
{% if item.payload.b_feedback %}
|
||||
<p><strong>B Feedback:</strong> {{ item.payload.b_feedback|default:''|safe }}</p>
|
||||
{% endif %}
|
||||
<li>C: {{ item.payload.c_answer|default:''|safe }}</li>
|
||||
{% if item.payload.c_feedback %}
|
||||
<p><strong>C Feedback:</strong> {{ item.payload.c_feedback|default:''|safe }}</p>
|
||||
{% endif %}
|
||||
<li>D: {{ item.payload.d_answer|default:''|safe }}</li>
|
||||
{% if item.payload.d_feedback %}
|
||||
<p><strong>D Feedback:</strong> {{ item.payload.d_feedback|default:''|safe }}</p>
|
||||
{% endif %}
|
||||
<li>E: {{ item.payload.e_answer|default:''|safe }}</li>
|
||||
{% if item.payload.e_feedback %}
|
||||
<p><strong>E Feedback:</strong> {{ item.payload.e_feedback|default:''|safe }}</p>
|
||||
{% endif %}
|
||||
|
||||
</ul>
|
||||
<p><strong>Best Answer:</strong> {{ item.payload.best_answer|default:'' }}
|
||||
</p>
|
||||
<p><strong>Feedback:</strong> {{ item.payload.feedback|default:''|safe }}</p>
|
||||
<p><strong>Sources:</strong>
|
||||
{% if item.payload.sources %}
|
||||
<ul>
|
||||
{% for source in item.payload.sources %}
|
||||
<li>{{ source }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
None
|
||||
{% endif %}
|
||||
</p>
|
||||
{% 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">
|
||||
<!-- Finding -->
|
||||
<div class="m2m-group" data-model="Finding"><strong>Finding:</strong>
|
||||
{% if 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>
|
||||
{% 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 %}
|
||||
{% 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 -->
|
||||
<div class="m2m-group" data-model="Condition"><strong>Condition:</strong>
|
||||
{% if 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>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if 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>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
<span class="text-muted m2m-none" style="display:none">(none)</span>
|
||||
{% if item.missing_map.condition %}
|
||||
<div class="small text-info">Would create: {{ item.missing_map.condition|join:", " }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Presentation -->
|
||||
<div class="m2m-group" data-model="Presentation"><strong>Presentation:</strong>
|
||||
{% if 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>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if 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>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
<span class="text-muted m2m-none" style="display:none">(none)</span>
|
||||
{% if item.missing_map.presentation %}
|
||||
<div class="small text-info">Would create: {{ item.missing_map.presentation|join:", " }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Subspecialty -->
|
||||
<div class="m2m-group" data-model="Subspecialty"><strong>Subspecialty:</strong>
|
||||
{% if 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>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if 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>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
<span class="text-muted m2m-none" style="display:none">(none)</span>
|
||||
{% if item.missing_map.subspecialty %}
|
||||
<div class="small text-info">Would create: {{ item.missing_map.subspecialty|join:", " }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<! -- Category -->
|
||||
<div class="m2m-group" data-model="Category"><strong>Category:</strong>
|
||||
{{ item.payload.category|default:'(none)' }}
|
||||
|
||||
</div>
|
||||
<!-- DEBUG: show raw resolved and missing maps for troubleshooting -->
|
||||
<div class="card-footer text-monospace small">
|
||||
<details>
|
||||
<summary>Debug: resolved_m2m / missing_map (click to expand)</summary>
|
||||
<pre style="white-space:pre-wrap;">resolved_m2m: {{ item.resolved_m2m|default:"{}" }}
|
||||
missing_map: {{ item.missing_map|default:"{}" }}</pre>
|
||||
</details>
|
||||
</div>
|
||||
<div>
|
||||
<label><input type="checkbox" name="selected" value="{{ item.index }}" checked> Include</label>
|
||||
</div>
|
||||
<!-- container for hidden exclude inputs for this item -->
|
||||
<div id="exclude-container-{{ item.index }}"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<label class="me-2"><input type="checkbox" name="allow_create_m2m" {% if allow_create_m2m %}checked{% endif %}> Create missing M2M</label>
|
||||
<button class="btn btn-success" type="submit">Import selected</button>
|
||||
<button class="btn btn-secondary" type="button" hx-get="{% url 'sbas:import_llm_questions' %}" hx-target="#import-preview">Cancel</button>
|
||||
</div>
|
||||
<div id="import-result" class="mt-3"></div>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
// Toggle exclude: clicking a M2M button will add/remove a hidden input and update styles
|
||||
function setupToggle(btn){
|
||||
btn.addEventListener('click', function(){
|
||||
const model = btn.dataset.model;
|
||||
const value = btn.dataset.value;
|
||||
|
||||
// Find all buttons with same model+value and toggle them together
|
||||
document.querySelectorAll('.m2m-toggle').forEach(function(b){
|
||||
if(b.dataset.model === model && b.dataset.value === value){
|
||||
const idx2 = b.dataset.idx;
|
||||
const container = document.getElementById('exclude-container-' + idx2);
|
||||
const inputName = 'exclude_' + idx2;
|
||||
const existing = container ? container.querySelector('input[data-model="' + model + '"][data-value="' + value + '"]') : null;
|
||||
if(existing){
|
||||
existing.remove();
|
||||
b.classList.remove('btn-outline-secondary');
|
||||
b.classList.remove('btn-secondary');
|
||||
b.classList.add('btn-success');
|
||||
} else {
|
||||
const inp = document.createElement('input');
|
||||
inp.type = 'hidden';
|
||||
inp.name = inputName;
|
||||
inp.value = model + ':::' + value;
|
||||
inp.setAttribute('data-model', model);
|
||||
inp.setAttribute('data-value', value);
|
||||
container.appendChild(inp);
|
||||
b.classList.remove('btn-success');
|
||||
b.classList.add('btn-outline-secondary');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Update empty placeholders after toggling
|
||||
refreshEmptyPlaceholders();
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('.m2m-toggle').forEach(setupToggle);
|
||||
// keep backward compatibility with older toggle-exclude class
|
||||
document.querySelectorAll('.toggle-exclude').forEach(setupToggle);
|
||||
|
||||
// Show '(none)' for empty groups
|
||||
function refreshEmptyPlaceholders(){
|
||||
document.querySelectorAll('.m2m-group').forEach(function(group){
|
||||
// consider a button 'active' only if it has the green btn-success class
|
||||
const activeButtons = group.querySelectorAll('.m2m-toggle.btn-success');
|
||||
const none = group.querySelector('.m2m-none');
|
||||
if(!none) return;
|
||||
if(activeButtons.length === 0){
|
||||
none.style.display = '';
|
||||
} else {
|
||||
none.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// run on load
|
||||
refreshEmptyPlaceholders();
|
||||
|
||||
// when toggling, buttons are not removed from DOM, so no need to re-run on click;
|
||||
// however if you later add server-side mutation that rewrites the fragment, call refreshEmptyPlaceholders after update.
|
||||
</script>
|
||||
|
||||
<!-- Bottom controls removed: submit and cancel are inside the form above -->
|
||||
@@ -0,0 +1,21 @@
|
||||
<div class="alert alert-info">Imported {{ created }} questions.</div>
|
||||
{% if errors %}
|
||||
<div class="alert alert-danger">
|
||||
<h5>Errors</h5>
|
||||
<ul>
|
||||
{% for e in errors %}
|
||||
<li>Item {{ e.index }}: {{ e.errors|join:', ' }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if actually_created %}
|
||||
<div class="alert alert-secondary">
|
||||
<h5>Created M2M records</h5>
|
||||
<ul>
|
||||
{% for k,v in actually_created.items %}
|
||||
<li>{{ k }}: {{ v|join:', ' }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -15,30 +15,115 @@
|
||||
<span>{{question.stem}}</span>
|
||||
<div>
|
||||
<ol class="abcde">
|
||||
<li>{{ question.a_answer }}</li>
|
||||
<li>{{ question.b_answer }}</li>
|
||||
<li>{{ question.c_answer }}</li>
|
||||
<li>{{ question.d_answer }}</li>
|
||||
<li>{{ question.e_answer }}</li>
|
||||
<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>
|
||||
Author: {% for user in question.author.all %}
|
||||
{{ author }},
|
||||
{% endfor %}
|
||||
Author(s):
|
||||
{% if question.author.exists %}
|
||||
{% for u in question.author.all %}
|
||||
{{ u.get_full_name|default:u.username }}{% if not forloop.last %}, {% endif %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
Unknown
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
||||
{% include 'question_notes.html' %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -82,6 +82,23 @@ urlpatterns = [
|
||||
views.UserAnswerDelete.as_view(),
|
||||
name="user_answer_delete",
|
||||
),
|
||||
path(
|
||||
"llm_prompts/",
|
||||
views.llm_prompt_view,
|
||||
name="llm_prompt_view",
|
||||
|
||||
)
|
||||
,
|
||||
path(
|
||||
"import_llm_questions/",
|
||||
views.import_llm_questions,
|
||||
name="import_llm_questions",
|
||||
),
|
||||
path(
|
||||
"import_llm_confirm/",
|
||||
views.import_llm_confirm,
|
||||
name="import_llm_confirm",
|
||||
),
|
||||
#path(
|
||||
# "exam/<int:pk>/scores/<int:cid>/<str:passcode>/",
|
||||
# views.exam_scores_cid_user,
|
||||
|
||||
+521
@@ -29,6 +29,22 @@ from django.http import Http404, HttpResponseBadRequest, JsonResponse
|
||||
from django.http import HttpResponseRedirect, HttpResponse
|
||||
|
||||
from .models import Question, Category, Exam, UserAnswer
|
||||
from django.views.decorators.http import require_http_methods
|
||||
from django.db import transaction
|
||||
import re
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
import uuid
|
||||
|
||||
from loguru import logger
|
||||
|
||||
try:
|
||||
import jsonschema
|
||||
except Exception:
|
||||
jsonschema = None
|
||||
|
||||
|
||||
|
||||
from django_tables2 import SingleTableView, SingleTableMixin
|
||||
from django_filters.views import FilterView
|
||||
@@ -436,3 +452,508 @@ def exam_clone2(request, exam_id):
|
||||
new_exam = exam.clone_model()
|
||||
return redirect("sbas:exam_update", pk=new_exam.id)
|
||||
|
||||
|
||||
|
||||
def llm_prompt_view(request):
|
||||
return render(request, "sbas/llm_prompt_view.html", {})
|
||||
|
||||
class LLMImportForm(forms.Form):
|
||||
file = forms.FileField(required=False, help_text="Upload a JSON or JSONL file matching the SBA import schema")
|
||||
raw = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.Textarea(attrs={"rows": 10, "cols": 80}),
|
||||
help_text="Or paste JSON, JSONL (newline-delimited), or multiple JSON documents separated by blank lines",
|
||||
)
|
||||
dry_run = forms.BooleanField(required=False, initial=True, help_text="Validate and preview only; do not save to database")
|
||||
allow_create_m2m = forms.BooleanField(required=False, initial=True, help_text="Automatically create missing many-to-many items when importing (if not dry-run)")
|
||||
|
||||
|
||||
def _resolve_m2m(model_class, value):
|
||||
"""Resolve an item which may be an int (pk) or a string (name/slug).
|
||||
Returns a queryset or empty list of matching objects.
|
||||
"""
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, int):
|
||||
try:
|
||||
return [model_class.objects.get(pk=value)]
|
||||
except model_class.DoesNotExist:
|
||||
return []
|
||||
if isinstance(value, str):
|
||||
# try exact name field, then case-insensitive contains
|
||||
qs = model_class.objects.filter(name__iexact=value)
|
||||
if not qs.exists():
|
||||
qs = model_class.objects.filter(name__icontains=value)
|
||||
return list(qs[:5])
|
||||
return []
|
||||
|
||||
|
||||
@login_required
|
||||
@user_passes_test(lambda u: u.is_superuser)
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def import_llm_questions(request):
|
||||
"""Upload a JSON/JSONL file of LLM-produced questions and import them.
|
||||
|
||||
Only superusers may use this view. The view validates each object against
|
||||
the agreed JSON schema (draft-07) and attempts to resolve M2M references
|
||||
by numeric id or by name. Returns a JSON report of created items and errors.
|
||||
"""
|
||||
schema = {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "SBA Question",
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"required": [
|
||||
"title",
|
||||
"stem",
|
||||
"a_answer",
|
||||
"b_answer",
|
||||
"c_answer",
|
||||
"d_answer",
|
||||
"e_answer",
|
||||
"best_answer",
|
||||
"category",
|
||||
],
|
||||
"properties": {
|
||||
"title": {"type": "string"},
|
||||
"stem": {"type": "string"},
|
||||
"a_answer": {"type": "string"},
|
||||
"a_feedback": {"type": "string"},
|
||||
"b_answer": {"type": "string"},
|
||||
"b_feedback": {"type": "string"},
|
||||
"c_answer": {"type": "string"},
|
||||
"c_feedback": {"type": "string"},
|
||||
"d_answer": {"type": "string"},
|
||||
"d_feedback": {"type": "string"},
|
||||
"e_answer": {"type": "string"},
|
||||
"e_feedback": {"type": "string"},
|
||||
"feedback": {"type": "string"},
|
||||
"best_answer": {"type": "string", "enum": ["a", "b", "c", "d", "e"]},
|
||||
"category": {"oneOf": [{"type": "string"}]},
|
||||
"finding": {"type": "array", "items": {"oneOf": [{"type": "integer"}, {"type": "string"}]}, "uniqueItems": True},
|
||||
"structure": {"type": "array", "items": {"oneOf": [{"type": "integer"}, {"type": "string"}]}, "uniqueItems": True},
|
||||
"condition": {"type": "array", "items": {"oneOf": [{"type": "integer"}, {"type": "string"}]}, "uniqueItems": True},
|
||||
"presentation": {"type": "array", "items": {"oneOf": [{"type": "integer"}, {"type": "string"}]}, "uniqueItems": True},
|
||||
"subspecialty": {"type": "array", "items": {"oneOf": [{"type": "integer"}, {"type": "string"}]}, "uniqueItems": True},
|
||||
"sources": {"type": "array", "items": {"type": "string"}},
|
||||
},
|
||||
}
|
||||
|
||||
if request.method == "GET":
|
||||
form = LLMImportForm()
|
||||
return render(request, "sbas/import_llm_questions.html", {"form": form})
|
||||
|
||||
# POST
|
||||
form = LLMImportForm(request.POST, request.FILES)
|
||||
if not form.is_valid():
|
||||
return JsonResponse({"ok": False, "errors": ["No file uploaded or invalid form"]}, status=400)
|
||||
|
||||
raw_text = None
|
||||
if form.cleaned_data.get("raw"):
|
||||
raw_text = form.cleaned_data.get("raw")
|
||||
elif form.cleaned_data.get("file"):
|
||||
f = form.cleaned_data["file"]
|
||||
raw_text = f.read().decode("utf-8")
|
||||
else:
|
||||
return JsonResponse({"ok": False, "errors": ["No file uploaded or text provided"]}, status=400)
|
||||
|
||||
# support: single JSON object, JSON array, JSONL (one JSON object per line),
|
||||
# or multiple JSON documents separated by one or more blank lines.
|
||||
candidates = []
|
||||
# sanitize raw_text by escaping backslashes that are not part of a valid
|
||||
# JSON escape sequence. This handles inputs containing LaTeX-style
|
||||
# sequences such as "\ge" which are invalid JSON (Invalid \escape).
|
||||
def _sanitize_backslashes(s: str):
|
||||
# valid escapes after backslash in JSON: \" \\ \/ \b \f \n \r \t and \uXXXX
|
||||
# This regex finds backslashes not followed by ", \\ , /, b, f, n, r, t, or u
|
||||
pattern = re.compile(r"\\(?!(?:[\"\\/bfnrtu]))")
|
||||
new_s, n = pattern.subn(r"\\\\", s)
|
||||
return new_s, n
|
||||
|
||||
raw_text_stripped = raw_text.strip()
|
||||
raw_text, sanitized_count = _sanitize_backslashes(raw_text_stripped)
|
||||
logger.debug("import_llm_questions: sanitized input length %d (escaped %d backslashes)", len(raw_text), sanitized_count)
|
||||
# Try whole-text JSON first (object or array)
|
||||
parse_error = None
|
||||
try:
|
||||
parsed = json.loads(raw_text)
|
||||
if isinstance(parsed, list):
|
||||
candidates = parsed
|
||||
elif isinstance(parsed, dict):
|
||||
candidates = [parsed]
|
||||
except Exception as e_outer:
|
||||
parse_error = e_outer
|
||||
# Try JSONL: each non-empty line is a JSON object
|
||||
for i, line in enumerate(raw_text.splitlines()):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
candidates.append(json.loads(line))
|
||||
except Exception:
|
||||
# not JSONL or some lines are multi-line JSON; fallthrough
|
||||
candidates = []
|
||||
break
|
||||
|
||||
# If JSONL didn't produce candidates, try splitting by blank lines
|
||||
if not candidates:
|
||||
blocks = [b.strip() for b in re.split(r"\n\s*\n", raw_text) if b.strip()]
|
||||
if len(blocks) > 1:
|
||||
for i, block in enumerate(blocks):
|
||||
try:
|
||||
candidates.append(json.loads(block))
|
||||
except Exception as e_block:
|
||||
return JsonResponse({"ok": False, "errors": [f"Invalid JSON in block {i+1}: {e_block}"]}, status=400)
|
||||
|
||||
if not candidates:
|
||||
# If still empty, return the original parse error if present
|
||||
msg = str(parse_error) if parse_error is not None else "No JSON objects found"
|
||||
logger.debug(f"Failed to parse input: {msg}")
|
||||
return JsonResponse({"ok": False, "errors": [f"Failed to parse input: {msg}"]}, status=400)
|
||||
|
||||
dry_run = bool(form.cleaned_data.get("dry_run"))
|
||||
allow_create_m2m = bool(form.cleaned_data.get("allow_create_m2m"))
|
||||
|
||||
report = {
|
||||
"total": len(candidates),
|
||||
"created": 0,
|
||||
"errors": [],
|
||||
"sanitized_backslashes": sanitized_count,
|
||||
"per_item": [],
|
||||
"would_create_m2m": {},
|
||||
"actually_created_m2m": {},
|
||||
}
|
||||
|
||||
if jsonschema is None:
|
||||
return JsonResponse({"ok": False, "errors": ["jsonschema library not available in environment"]}, status=500)
|
||||
|
||||
validator = jsonschema.Draft7Validator(schema)
|
||||
|
||||
from atlas.models import Finding, Structure, Condition, Presentation, Subspecialty
|
||||
|
||||
# Aggregators for M2M creation suggestions
|
||||
aggregated_would_create = defaultdict(set)
|
||||
aggregated_actually_created = defaultdict(list)
|
||||
|
||||
for idx, payload in enumerate(candidates):
|
||||
item_report = {"index": idx, "status": None, "errors": [], "missing_m2m": [], "resolved_m2m": {}, "payload": payload}
|
||||
for err in validator.iter_errors(payload):
|
||||
item_report["errors"].append(err.message)
|
||||
if item_report["errors"]:
|
||||
logger.debug(f"Validation errors for item {idx}: {item_report['errors']}")
|
||||
report["errors"].append({"index": idx, "errors": item_report["errors"]})
|
||||
item_report["status"] = "invalid"
|
||||
report["per_item"].append(item_report)
|
||||
continue
|
||||
|
||||
try:
|
||||
# Prepare category resolution
|
||||
cat_val = payload.get("category")
|
||||
cat_obj = None
|
||||
if isinstance(cat_val, str):
|
||||
if not dry_run:
|
||||
cat_obj, _ = Category.objects.get_or_create(category=cat_val)
|
||||
else:
|
||||
# dry-run preview: show that category would be used/created
|
||||
item_report.setdefault("category_preview", cat_val)
|
||||
|
||||
# M2M resolution and missing detection
|
||||
m2m_map = {}
|
||||
for key, model_cls in (
|
||||
("finding", Finding),
|
||||
("structure", Structure),
|
||||
("condition", Condition),
|
||||
("presentation", Presentation),
|
||||
("subspecialty", Subspecialty),
|
||||
):
|
||||
vals = payload.get(key) or []
|
||||
resolved = []
|
||||
missing = []
|
||||
for v in vals:
|
||||
if isinstance(v, int):
|
||||
try:
|
||||
resolved.append(model_cls.objects.get(pk=v))
|
||||
except model_cls.DoesNotExist:
|
||||
missing.append(v)
|
||||
elif isinstance(v, str):
|
||||
qs = model_cls.objects.filter(name__iexact=v)
|
||||
if not qs.exists():
|
||||
qs = model_cls.objects.filter(name__icontains=v)
|
||||
if qs.exists():
|
||||
resolved.extend(list(qs[:5]))
|
||||
else:
|
||||
missing.append(v)
|
||||
|
||||
m2m_map[key] = {"resolved": resolved, "missing": missing}
|
||||
item_report["resolved_m2m"][key] = [o.name for o in resolved]
|
||||
# Always include an entry for this M2M (values may be empty)
|
||||
item_report["missing_m2m"].append({"model": model_cls.__name__, "values": missing})
|
||||
if missing:
|
||||
for v in missing:
|
||||
aggregated_would_create[model_cls.__name__].add(v)
|
||||
|
||||
# Build a simple map of missing values per model (stringified) for robust template rendering
|
||||
# Provide both lowercase and capitalized keys so templates referencing either form will work
|
||||
# missing_map keys are lowercase (matching m2m_map keys)
|
||||
missing_map = {k: [str(x) for x in v["missing"]] for k, v in m2m_map.items()}
|
||||
item_report["missing_map"] = missing_map
|
||||
|
||||
# Debug: log missing_map and resolved_m2m for troubleshooting
|
||||
logger.debug(
|
||||
"import_llm_questions: item %s missing_map_keys=%s resolved_keys=%s",
|
||||
idx,
|
||||
list(missing_map.keys()),
|
||||
list((item_report.get("resolved_m2m") or {}).keys()),
|
||||
)
|
||||
|
||||
# If dry-run, do not save; just report what would happen
|
||||
if dry_run:
|
||||
item_report["status"] = "would_create"
|
||||
report["per_item"].append(item_report)
|
||||
continue
|
||||
|
||||
# Not dry-run: create Question and resolve/possibly create missing M2M
|
||||
with transaction.atomic():
|
||||
q = Question()
|
||||
q.stem = payload.get("stem", "").strip()
|
||||
q.title = payload.get("title")
|
||||
|
||||
q.a_answer = payload.get("a_answer", "").strip()
|
||||
q.a_feedback = payload.get("a_feedback", "")
|
||||
q.b_answer = payload.get("b_answer", "").strip()
|
||||
q.b_feedback = payload.get("b_feedback", "")
|
||||
q.c_answer = payload.get("c_answer", "").strip()
|
||||
q.c_feedback = payload.get("c_feedback", "")
|
||||
q.d_answer = payload.get("d_answer", "").strip()
|
||||
q.d_feedback = payload.get("d_feedback", "")
|
||||
q.e_answer = payload.get("e_answer", "").strip()
|
||||
q.e_feedback = payload.get("e_feedback", "")
|
||||
q.feedback = payload.get("feedback", "")
|
||||
q.best_answer = payload.get("best_answer")
|
||||
if cat_obj:
|
||||
q.category = cat_obj
|
||||
# populate sources JSONField if provided (accept list or single string)
|
||||
srcs = payload.get("sources")
|
||||
if isinstance(srcs, list):
|
||||
q.sources = srcs
|
||||
elif srcs:
|
||||
q.sources = [str(srcs)]
|
||||
q.save()
|
||||
# Add current user as author of the imported question
|
||||
try:
|
||||
q.author.add(request.user)
|
||||
except Exception:
|
||||
logger.exception("Failed to add author for imported question idx=%s", idx)
|
||||
|
||||
# For each M2M, create missing items if allowed and attach resolved
|
||||
for key, model_cls in (
|
||||
("finding", Finding),
|
||||
("structure", Structure),
|
||||
("condition", Condition),
|
||||
("presentation", Presentation),
|
||||
("subspecialty", Subspecialty),
|
||||
):
|
||||
resolved = m2m_map[key]["resolved"]
|
||||
missing_vals = m2m_map[key]["missing"]
|
||||
# create missing if allowed
|
||||
created_objs = []
|
||||
if missing_vals and allow_create_m2m:
|
||||
for v in missing_vals:
|
||||
# create with name=v (use get_or_create to avoid races)
|
||||
obj, created = model_cls.objects.get_or_create(name=v)
|
||||
created_objs.append(obj)
|
||||
aggregated_actually_created[model_cls.__name__].append(obj.pk)
|
||||
|
||||
# final list to attach
|
||||
final_objs = resolved + created_objs
|
||||
if final_objs:
|
||||
getattr(q, key).add(*[o.pk for o in final_objs])
|
||||
|
||||
report["created"] += 1
|
||||
item_report["status"] = "created"
|
||||
item_report["created_pk"] = q.pk
|
||||
report["per_item"].append(item_report)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Failed to import item %s", idx)
|
||||
item_report["status"] = "error"
|
||||
item_report["errors"].append(str(e))
|
||||
report["errors"].append({"index": idx, "errors": [str(e)]})
|
||||
report["per_item"].append(item_report)
|
||||
|
||||
# Flatten aggregated_would_create sets to lists
|
||||
for k, v in aggregated_would_create.items():
|
||||
report["would_create_m2m"][k] = sorted(list(v))
|
||||
for k, v in aggregated_actually_created.items():
|
||||
report["actually_created_m2m"][k] = v
|
||||
|
||||
# If this was a dry-run and the request came from HTMX, render a preview partial and store the parsed candidates in session
|
||||
if dry_run and request.headers.get("HX-Request"):
|
||||
session_key = f"llm_import_{uuid.uuid4().hex}"
|
||||
# store minimal necessary data in session (the raw payloads)
|
||||
request.session[session_key] = candidates
|
||||
request.session.modified = True
|
||||
preview_ctx = {
|
||||
"report": report,
|
||||
"items": report["per_item"],
|
||||
"payloads": candidates,
|
||||
"session_key": session_key,
|
||||
"allow_create_m2m": allow_create_m2m,
|
||||
}
|
||||
return render(request, "sbas/partials/import_preview.html", preview_ctx)
|
||||
|
||||
# Non-HTMX callers get JSON
|
||||
return JsonResponse({"ok": True, "report": report})
|
||||
|
||||
|
||||
@login_required
|
||||
@user_passes_test(lambda u: u.is_superuser)
|
||||
@require_http_methods(["POST"])
|
||||
def import_llm_confirm(request):
|
||||
"""Confirm import of previously-previewed candidates stored in session.
|
||||
|
||||
Expects POST with 'session_key' and 'selected' (comma-separated indices) and optional allow_create_m2m.
|
||||
Returns an HTML fragment suitable for HTMX replacement summarising the import results.
|
||||
"""
|
||||
session_key = request.POST.get("session_key")
|
||||
# support both a comma-separated 'selected' string or multiple 'selected' values
|
||||
selected_raw = request.POST.get("selected")
|
||||
selected_list = request.POST.getlist("selected")
|
||||
allow_create_m2m = request.POST.get("allow_create_m2m") in ("on", "true", "True", "1")
|
||||
if selected_list:
|
||||
# selected_list contains strings of indices
|
||||
selected = ",".join(selected_list)
|
||||
else:
|
||||
selected = selected_raw
|
||||
if not session_key or session_key not in request.session:
|
||||
return render(request, "sbas/partials/import_error.html", {"errors": ["Session expired or invalid; please re-run preview."]}, status=400)
|
||||
|
||||
candidates = request.session.get(session_key, [])
|
||||
indices = []
|
||||
if selected:
|
||||
for part in selected.split(','):
|
||||
try:
|
||||
indices.append(int(part))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# default: import all if none specified
|
||||
if not indices:
|
||||
indices = list(range(len(candidates)))
|
||||
|
||||
# perform actual import for selected indices
|
||||
from atlas.models import Finding, Structure, Condition, Presentation, Subspecialty
|
||||
|
||||
# Parse exclude inputs of the form exclude_<idx>="Model:::value" (may be multiple per idx)
|
||||
excluded_map = defaultdict(lambda: defaultdict(set))
|
||||
for k, vlist in request.POST.lists():
|
||||
if not k.startswith("exclude_"):
|
||||
continue
|
||||
try:
|
||||
idx_str = k.split("exclude_")[1]
|
||||
idx_key = int(idx_str)
|
||||
except Exception:
|
||||
continue
|
||||
for entry in vlist:
|
||||
# entry format: Model:::value
|
||||
try:
|
||||
model_name, val = entry.split(":::", 1)
|
||||
excluded_map[idx_key][model_name].add(val)
|
||||
except Exception:
|
||||
continue
|
||||
# Debug: log exclusions parsed from POST
|
||||
try:
|
||||
# convert sets to lists for logging
|
||||
log_map = {i: {m: list(vals) for m, vals in models.items()} for i, models in excluded_map.items()}
|
||||
logger.debug("import_llm_confirm: excluded_map=%s", log_map)
|
||||
except Exception:
|
||||
logger.exception("Failed to log excluded_map")
|
||||
|
||||
created = 0
|
||||
errors = []
|
||||
actually_created = defaultdict(list)
|
||||
for idx in indices:
|
||||
try:
|
||||
payload = candidates[idx]
|
||||
except Exception:
|
||||
errors.append({"index": idx, "errors": ["Missing candidate"]})
|
||||
continue
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
q = Question()
|
||||
q.stem = payload.get("stem", "").strip()
|
||||
q.title = payload.get("title")
|
||||
q.a_answer = payload.get("a_answer", "").strip()
|
||||
q.a_feedback = payload.get("a_feedback", "")
|
||||
q.b_answer = payload.get("b_answer", "").strip()
|
||||
q.b_feedback = payload.get("b_feedback", "")
|
||||
q.c_answer = payload.get("c_answer", "").strip()
|
||||
q.c_feedback = payload.get("c_feedback", "")
|
||||
q.d_answer = payload.get("d_answer", "").strip()
|
||||
q.d_feedback = payload.get("d_feedback", "")
|
||||
q.e_answer = payload.get("e_answer", "").strip()
|
||||
q.e_feedback = payload.get("e_feedback", "")
|
||||
q.feedback = payload.get("feedback", "")
|
||||
q.best_answer = payload.get("best_answer")
|
||||
cat_val = payload.get("category")
|
||||
if isinstance(cat_val, str):
|
||||
cat_obj, _ = Category.objects.get_or_create(category=cat_val)
|
||||
q.category = cat_obj
|
||||
# populate sources JSONField if provided (accept list or single string)
|
||||
srcs = payload.get("sources")
|
||||
if isinstance(srcs, list):
|
||||
q.sources = srcs
|
||||
elif srcs:
|
||||
q.sources = [str(srcs)]
|
||||
q.save()
|
||||
# Add current user as author of the imported question
|
||||
try:
|
||||
q.author.add(request.user)
|
||||
except Exception:
|
||||
logger.exception("Failed to add author for confirmed import idx=%s", idx)
|
||||
|
||||
for key, model_cls in (("finding", Finding), ("structure", Structure), ("condition", Condition), ("presentation", Presentation), ("subspecialty", Subspecialty)):
|
||||
vals = payload.get(key) or []
|
||||
final_objs = []
|
||||
for v in vals:
|
||||
if isinstance(v, int):
|
||||
try:
|
||||
final_objs.append(model_cls.objects.get(pk=v))
|
||||
except model_cls.DoesNotExist:
|
||||
continue
|
||||
elif isinstance(v, str):
|
||||
qs = model_cls.objects.filter(name__iexact=v)
|
||||
if not qs.exists():
|
||||
qs = model_cls.objects.filter(name__icontains=v)
|
||||
if qs.exists():
|
||||
# add resolved objects unless excluded for this item
|
||||
for obj in list(qs[:5]):
|
||||
if obj.name in excluded_map.get(idx, {}).get(model_cls.__name__, set()):
|
||||
logger.debug("Skipping resolved attach for idx=%s model=%s name=%s due to exclusion", idx, model_cls.__name__, obj.name)
|
||||
continue
|
||||
final_objs.append(obj)
|
||||
else:
|
||||
if allow_create_m2m:
|
||||
# skip creation if excluded for this item
|
||||
if v in excluded_map.get(idx, {}).get(model_cls.__name__, set()):
|
||||
logger.debug("Skipping creation for idx=%s model=%s name=%s due to exclusion", idx, model_cls.__name__, v)
|
||||
continue
|
||||
obj, created_flag = model_cls.objects.get_or_create(name=v)
|
||||
final_objs.append(obj)
|
||||
actually_created[model_cls.__name__].append(obj.pk)
|
||||
if final_objs:
|
||||
getattr(q, key).add(*[o.pk for o in final_objs])
|
||||
|
||||
created += 1
|
||||
except Exception as e:
|
||||
errors.append({"index": idx, "errors": [str(e)]})
|
||||
|
||||
# cleanup session
|
||||
try:
|
||||
del request.session[session_key]
|
||||
request.session.modified = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return render(request, "sbas/partials/import_result.html", {"created": created, "errors": errors, "actually_created": dict(actually_created)})
|
||||
@@ -18,13 +18,22 @@
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
{% render_table table %}
|
||||
{% 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
|
||||
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="exam-options"></div>
|
||||
|
||||
<div id="action-result" class="mt-2"></div>
|
||||
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user