Compare commits
52
Commits
4f47354ca1
...
e687b55e6a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e687b55e6a | ||
|
|
d19e937bd4 | ||
|
|
fa68034f03 | ||
|
|
68f45c8561 | ||
|
|
be68fcd0b9 | ||
|
|
e819645278 | ||
|
|
9efec6b79b | ||
|
|
3fb5bc2ae8 | ||
|
|
6700c6a7d2 | ||
|
|
55621b2dcc | ||
|
|
b417266e56 | ||
|
|
6d589ef630 | ||
|
|
8addc900f5 | ||
|
|
a055e138c4 | ||
|
|
132f3c48c9 | ||
|
|
07f121fb69 | ||
|
|
c7fa3a096c | ||
|
|
196d472608 | ||
|
|
1a04064e6b | ||
|
|
a49f198d26 | ||
|
|
8663482a6b | ||
|
|
5e4fea38d7 | ||
|
|
45e50e9e93 | ||
|
|
8dac99b6d3 | ||
|
|
a4f4cb9e96 | ||
|
|
5e84ba3178 | ||
|
|
4050193cec | ||
|
|
c975b3c967 | ||
|
|
329394dcf0 | ||
|
|
6cb1303630 | ||
|
|
9b9cd8c813 | ||
|
|
593eafd427 | ||
|
|
7f68fc23c6 | ||
|
|
bad0d400a7 | ||
|
|
ae644f9c03 | ||
|
|
5b8896e2b2 | ||
|
|
66711f5c15 | ||
|
|
d342e7e047 | ||
|
|
bdb7aff5d1 | ||
|
|
ac9faeccbd | ||
|
|
1e7d765fc2 | ||
|
|
e3055e3998 | ||
|
|
f6f208b065 | ||
|
|
815d853599 | ||
|
|
9cffdb3636 | ||
|
|
a2a43e51f9 | ||
|
|
101326fea2 | ||
|
|
b157fe8b00 | ||
|
|
33b6d0d935 | ||
|
|
3daad1b83b | ||
|
|
512b222ccc | ||
|
|
5294c5f2f6 |
@@ -6,27 +6,31 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% 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 %}
|
||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
+19
-2
@@ -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
|
||||||
@@ -831,4 +833,19 @@ 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)
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Generated by Django 5.1.4 on 2025-10-22 19:19
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('contenttypes', '0002_remove_content_type_name'),
|
||||||
|
('generic', '0025_cimarseriesthumbnail'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='QuestionReview',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('object_id', models.PositiveIntegerField()),
|
||||||
|
('comment', models.TextField(blank=True)),
|
||||||
|
('status', models.CharField(choices=[('AC', 'Accepted'), ('OD', 'Outdated'), ('ER', 'Error'), ('RJ', 'Rejected')], default='AC', max_length=2)),
|
||||||
|
('created_on', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||||
|
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -219,6 +219,8 @@ class QuestionBase(models.Model, AuthorMixin, QuestionMixin):
|
|||||||
|
|
||||||
notes = GenericRelation("generic.QuestionNote")
|
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 •
|
||||||
|
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>
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -15,10 +15,12 @@
|
|||||||
</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=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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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"))
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 () {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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">
|
||||||
{{ filter.form }}
|
<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>
|
||||||
|
|
||||||
<input class="btn btn-primary btn-sm mt-1 mb-1" type="submit" />
|
<div class="col-12 col-lg-9">
|
||||||
</form>
|
<div class="table-responsive">
|
||||||
</div>
|
{% autoescape off %}
|
||||||
{% autoescape off %}
|
{% load render_table from django_tables2 %}
|
||||||
{% load render_table from django_tables2 %}
|
{% render_table table %}
|
||||||
{% render_table table %}
|
{% endautoescape %}
|
||||||
{% endautoescape %}
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
+1
-1
@@ -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
@@ -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>
|
||||||
@@ -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> 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>
|
||||||
|
|
||||||
{% endblock %}
|
<button class="btn btn-primary filter-toggle d-md-none" type="button" data-bs-toggle="collapse" data-bs-target="#bottom-filter-body" aria-expanded="false" aria-controls="#bottom-filter-body">Filters</button>
|
||||||
|
|
||||||
|
<div class="floating-filter shadow-lg">
|
||||||
|
<div id="bottom-filter-body" class="collapse">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
|
<div class="fw-bold">Filters</div>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#bottom-filter-body" aria-expanded="true">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- filter body collapsible so minibar stays visible -->
|
||||||
|
<div class="filter-body mt-2">
|
||||||
|
<form action="" method="get">
|
||||||
|
<div class="row g-3">
|
||||||
|
{% for field in filter.form.visible_fields %}
|
||||||
|
<div class="col-12 col-sm-6 col-md-4 col-lg-3">
|
||||||
|
<div class="filter-card">
|
||||||
|
<div class="form-group mb-1">
|
||||||
|
{{ field.label_tag }}
|
||||||
|
</div>
|
||||||
|
<div class="form-group mb-1">
|
||||||
|
{{ field }}
|
||||||
|
</div>
|
||||||
|
{% if field.errors %}
|
||||||
|
<div class="invalid-feedback d-block">{{ field.errors|striptags }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if field.help_text %}
|
||||||
|
<div class="filter-help">{{ field.help_text }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 d-flex gap-2 filter-actions">
|
||||||
|
<button class="btn btn-primary btn-sm" type="submit"><i class="bi bi-search"></i> Apply</button>
|
||||||
|
<a class="btn btn-outline-secondary btn-sm" href="?"><i class="bi bi-arrow-counterclockwise"></i> Reset</a>
|
||||||
|
<button class="btn btn-light btn-sm" type="button" data-bs-toggle="collapse" data-bs-target="#bottom-filter-body">Close</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Floating filter bar (fixed at bottom) -->
|
||||||
|
<style>
|
||||||
|
.floating-filter {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 1030;
|
||||||
|
background: var(--bs-body-bg);
|
||||||
|
border-top: 1px solid rgba(0,0,0,0.08);
|
||||||
|
padding: 0.5rem 0.75rem 1rem 0.75rem;
|
||||||
|
}
|
||||||
|
.floating-filter .filter-body {
|
||||||
|
max-height: 50vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-top: .5rem;
|
||||||
|
}
|
||||||
|
.floating-filter .filter-toggle {
|
||||||
|
position: fixed;
|
||||||
|
right: 1rem;
|
||||||
|
bottom: 1rem;
|
||||||
|
z-index: 1040;
|
||||||
|
}
|
||||||
|
/* minibar/tab styling - small visible handle so panel isn't fully hidden */
|
||||||
|
.bottom-filter-minibar {
|
||||||
|
position: fixed;
|
||||||
|
right: 1rem;
|
||||||
|
bottom: 1rem;
|
||||||
|
z-index: 1045;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.bottom-filter-minibar .btn { box-shadow: 0 2px 10px rgba(0,0,0,0.12); }
|
||||||
|
|
||||||
|
/* pretty filter cards */
|
||||||
|
.filter-card {
|
||||||
|
background: var(--bs-white);
|
||||||
|
border: 1px solid rgba(0,0,0,0.04);
|
||||||
|
border-radius: .5rem;
|
||||||
|
padding: .5rem;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.filter-card .form-group { margin-bottom: 0; }
|
||||||
|
.filter-card label { font-weight: 600; font-size: .9rem; }
|
||||||
|
.filter-help { font-size: .8rem; color: #6c757d; }
|
||||||
|
.filter-actions { padding-top: .5rem; }
|
||||||
|
|
||||||
|
/* active filter count badge on minibar */
|
||||||
|
.filter-count-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -6px;
|
||||||
|
right: -6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode overrides */
|
||||||
|
.floating-filter {
|
||||||
|
background: #0f1720; /* near-black */
|
||||||
|
color: #e6eef6;
|
||||||
|
border-top-color: rgba(255,255,255,0.06);
|
||||||
|
}
|
||||||
|
.bottom-filter-minibar .btn {
|
||||||
|
background: rgba(255,255,255,0.02);
|
||||||
|
color: #e6eef6;
|
||||||
|
border-color: rgba(255,255,255,0.06);
|
||||||
|
}
|
||||||
|
.filter-card {
|
||||||
|
background: #0b1220;
|
||||||
|
border-color: rgba(255,255,255,0.04);
|
||||||
|
color: #e6eef6;
|
||||||
|
}
|
||||||
|
/* target common form-control/form-select/select/textarea so native selects and bootstrap styles
|
||||||
|
are overridden for dark theme inside filter cards */
|
||||||
|
.filter-card .form-control,
|
||||||
|
.filter-card .form-select,
|
||||||
|
.filter-card input,
|
||||||
|
.filter-card select,
|
||||||
|
.filter-card textarea {
|
||||||
|
background: #07101a !important;
|
||||||
|
color: #f1f5f9 !important;
|
||||||
|
border-color: rgba(255,255,255,0.06) !important;
|
||||||
|
}
|
||||||
|
/* options in native multi-selects */
|
||||||
|
.filter-card select option,
|
||||||
|
.filter-card .form-select option {
|
||||||
|
background: #07101a !important;
|
||||||
|
color: #f1f5f9 !important;
|
||||||
|
}
|
||||||
|
/* placeholder color */
|
||||||
|
.filter-card .form-control::placeholder { color: rgba(255,255,255,0.5) !important; }
|
||||||
|
.filter-help { color: #9fb0c7; }
|
||||||
|
.badge.bg-primary.filter-count-badge { background: #0d6efd; color: #fff; }
|
||||||
|
.btn-outline-secondary { color: #e6eef6; border-color: rgba(255,255,255,0.06); }
|
||||||
|
.btn-light { background: #111827; color: #e6eef6; border-color: rgba(255,255,255,0.04); }
|
||||||
|
.filter-card label { color: #e6eef6; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user